-- ########################################################
--  DetentionRolls
--  Fair and transparent loot rolling for raids
-- ########################################################

local addonName = ...

local COMM_PREFIX = "DetRollsV1"
local COMM_DELIM = string.char(29)
local MAX_RECENT_EVENTS = 40
local SNAPSHOT_CHUNK_SIZE = 220
local AUTHORITY_FRESH_SECONDS = 45
local AUTHORITY_STATUS_INTERVAL_SECONDS = 15
local ROLLOFF_DURATION_SECONDS = 10
local CATEGORY_PRIORITY_ORDER = {
    tmog = 1,
    offspec = 2,
    upgrade = 3,
    bis = 4,
    set2 = 5,
    set4 = 5,
}

local defaults = {
    hideInCombat = false,
    minimizeInCombat = false,
    minimapPos = 45,
    pinnedToMinimap = false,
    toggleStrata = "HIGH",
    buttonAlpha = 1.0,
    framePos = { point = "CENTER", x = 0, y = 0 },
    scale = 1.0,
    strata = "MEDIUM",
    hoverMode = false,
    locked = false,
    vertical = false,
    alwaysShowButtons = false,
    frameAlpha = 0.0,

    fairnessEnabled = true,
    categoryPriorityMode = false,
    penaltyOnWin = 0.30,
    recoveryPerBoss = 0.05,
    minMultiplier = 0.40,

    raidActive = false,
    raidActivationPrompted = false,
    showCompactLeaderboard = true,
    autoOpen = true,
    autoClose = false,
    autoTrade = false,
    autoPassNonLeader = true,
    leaderAutoLootMode = "need_or_greed",
    testMode = false,
    testLootCount = 6,

    roster = {},
    weights = {},
    wins = {},
    authorityChain = {},
    recentEvents = {},
    lootSession = {},
    leaderboardVisible = true,
    lastAppliedBossId = nil,
    lastSyncSeq = 0,
    stateTimestamp = 0,
    stateUpdatedAt = "",
    stateOwnerKey = "",
    stateOwnerName = "",
    stateReason = "",
}

local rolls = {
    { name = "Offspec",   category = "offspec", min = 0, max = 50,  msg = "{rt7} < Offspec > Roll {rt7}", tooltipTitle = "For offspec and side builds" },
    { name = "Upgrade",   category = "upgrade", min = 0, max = 100, msg = "{rt1} < Upgrade > Roll {rt1}", tooltipTitle = "Main-spec upgrade" },
    { name = "BiS",       category = "bis",     min = 0, max = 200, msg = "{rt8} < BiS > Roll {rt8}", tooltipTitle = "Best in Slot!", isGold = true },
    { name = "2-Piece",   category = "set2",    min = 0, max = 200, msg = "{rt1}{rt1} < 2-Piece Set > Roll {rt1}{rt1}", tooltipTitle = "2-piece set bonus", isGold = true },
    { name = "4-Piece",   category = "set4",    min = 0, max = 200, msg = "{rt1}{rt1}{rt1}{rt1} < 4-Piece Set > Roll {rt1}{rt1}{rt1}{rt1}", tooltipTitle = "4-piece set bonus", isGold = true },
    { name = "TMOG",      category = "tmog",    min = 0, max = 10,  msg = "{rt3} < Transmog > Roll {rt3}", tooltipTitle = "For style points" },
}

local saved
local savedGlobal
local dbHandle
local frame
local toggleButton
local contextMenu
local importFrame
local importFrameBlocker
local transparencyPanel
local leaderboardFrame
local lootSessionFrame
local lootSessionTicker
local selectedSessionItemId
local authorityStatusTicker

local snapshotInbox = {}
local authorityStatusInbox = {}
local pendingLocalRoll
local rollSubmissionThrottle = 0
local pendingActivationPopup = false
local fakeLootSerial = 0
local raidRosterCache
local raidRosterCacheDirty = true
local bossLootSessionActive = false
local bossLootSessionEncounterId
local bossLootSessionExpiresAt = 0
local trackedMinimizeWindows = {}
local recentBossRecovery = {}

local refreshLeaderboardPopup
local refreshLootSessionWindow
local touchPersistentState
local shouldBroadcastAuthorityState
local sendSnapshot
local createLootSessionWindow
local showLootUiWindows
local doRoll
local serializeKeyList
local deserializeKeyList
local hasActiveSessionRolls
local tryAutoTradeAwardedItems
local isInRaid
local isRaidLeader
local SessionGate = {}
local UiAutomation = {}
local TradeAutomation = {}
local PersistenceAdapter = {}
local RosterEditor = {}

local function deepCopy(tbl)
    if type(tbl) ~= "table" then return tbl end
    local copy = {}
    for key, value in pairs(tbl) do
        copy[key] = deepCopy(value)
    end
    return copy
end

local function ensureDefaults()
    local legacySnapshot
    if type(DetentionRollsDB) == "table" and DetentionRollsDB.profile == nil and DetentionRollsDB.global == nil then
        legacySnapshot = deepCopy(DetentionRollsDB)
    end

    local aceDB = _G.LibStub and LibStub("AceDB-3.0", true)
    if aceDB then
        if not dbHandle then
            local aceDefaults = {
                global = {
                    schemaVersion = 1,
                    migratedLegacy = false,
                    migratedAt = nil,
                    legacyBackup = nil,
                },
                profile = deepCopy(defaults),
            }
            dbHandle = aceDB:New("DetentionRollsDB", aceDefaults, true)
        end

        saved = dbHandle.profile
        savedGlobal = dbHandle.global

        if legacySnapshot and not savedGlobal.migratedLegacy then
            for key, value in pairs(legacySnapshot) do
                if key ~= "profile" and key ~= "global" and key ~= "profiles" and key ~= "profileKeys" and key ~= "namespaces" then
                    if saved[key] == nil then
                        saved[key] = deepCopy(value)
                    elseif type(saved[key]) == "table" and type(value) == "table" then
                        for nestedKey, nestedValue in pairs(value) do
                            if saved[key][nestedKey] == nil then
                                saved[key][nestedKey] = deepCopy(nestedValue)
                            end
                        end
                    end
                end
            end
            savedGlobal.legacyBackup = legacySnapshot
            savedGlobal.migratedLegacy = true
            savedGlobal.migratedAt = time()
        end
    else
        if not DetentionRollsDB then
            DetentionRollsDB = {}
        end

        saved = DetentionRollsDB

        for key, value in pairs(defaults) do
            if saved[key] == nil then
                saved[key] = deepCopy(value)
            elseif type(value) == "table" and type(saved[key]) ~= "table" then
                saved[key] = deepCopy(value)
            end
        end
    end

    -- Legacy compatibility: retain old key but drive behavior through minimizeInCombat.
    if saved.minimizeInCombat == nil then
        saved.minimizeInCombat = saved.hideInCombat == true
    end
    saved.hideInCombat = saved.minimizeInCombat

    -- Retired setting kept only for migration compatibility.
    saved.vertical = false
end

local function invalidateRaidRosterCache()
    raidRosterCache = nil
    raidRosterCacheDirty = true
end

local function canMoveWindows()
    return not (saved and saved.locked)
end

local function setBossLootSession(active, encounterId, ttlSeconds)
    bossLootSessionActive = active == true
    if bossLootSessionActive then
        bossLootSessionEncounterId = tonumber(encounterId) or 0
        local ttl = tonumber(ttlSeconds) or 240
        bossLootSessionExpiresAt = time() + math.max(30, ttl)
    else
        bossLootSessionEncounterId = nil
        bossLootSessionExpiresAt = 0
    end
end

local function isBossLootSessionOpen()
    if not bossLootSessionActive then
        return false
    end
    if bossLootSessionExpiresAt > 0 and time() > bossLootSessionExpiresAt then
        setBossLootSession(false)
        return false
    end
    return true
end

local function registerMinimizeWindow(window)
    if not window then
        return
    end
    trackedMinimizeWindows[window] = trackedMinimizeWindows[window] or {
        autoMinimized = false,
    }
end

local function minimizeTrackedWindows()
    if not saved or not saved.minimizeInCombat then
        return
    end
    for window, state in pairs(trackedMinimizeWindows) do
        if window and window:IsShown() then
            state.autoMinimized = true
            window:Hide()
        end
    end
end

local function restoreTrackedWindowsAfterCombat()
    if not saved or not saved.minimizeInCombat then
        return
    end
    for window, state in pairs(trackedMinimizeWindows) do
        if window and state.autoMinimized then
            state.autoMinimized = false
            window:Show()
        end
    end
end

local function canAutoOpenLootWindows()
    return saved
        and saved.raidActive
        and saved.autoOpen
        and isInRaid()
        and isBossLootSessionOpen()
end

local function applyAutoCloseIfComplete()
    if not saved or not saved.autoClose then
        return
    end
    if isBossLootSessionOpen() then
        return
    end
    if hasActiveSessionRolls and hasActiveSessionRolls() then
        return
    end
    if lootSessionFrame and lootSessionFrame:IsShown() then
        lootSessionFrame:Hide()
    end
    if leaderboardFrame and leaderboardFrame:IsShown() then
        leaderboardFrame:Hide()
    end
end

function SessionGate:StartBossSession(encounterId, ttlSeconds)
    setBossLootSession(true, encounterId, ttlSeconds or 240)
end

function SessionGate:StopBossSession()
    setBossLootSession(false)
end

function SessionGate:CanAutoOpen()
    return canAutoOpenLootWindows()
end

function UiAutomation:RegisterWindow(window)
    registerMinimizeWindow(window)
end

function UiAutomation:OnEnterCombat()
    minimizeTrackedWindows()
end

function UiAutomation:OnLeaveCombat()
    restoreTrackedWindowsAfterCombat()
end

function UiAutomation:ApplyAutoClose()
    applyAutoCloseIfComplete()
end

function TradeAutomation:TryAutoTrade()
    if tryAutoTradeAwardedItems then
        tryAutoTradeAwardedItems()
    end
end

function PersistenceAdapter:Initialize()
    ensureDefaults()
end

function PersistenceAdapter:ResetCurrentProfile()
    if dbHandle and dbHandle.ResetProfile then
        dbHandle:ResetProfile()
        saved = dbHandle.profile
        return
    end
    wipe(DetentionRollsDB)
    ensureDefaults()
end

local function startWindowDrag(self)
    if canMoveWindows() then
        self:StartMoving()
    end
end

local function stopWindowDrag(self, onStop)
    self:StopMovingOrSizing()
    if onStop then
        onStop(self)
    end
end

local function applyWindowLockState(window)
    if not window then
        return
    end

    window:SetMovable(canMoveWindows())
    window:EnableMouse(true)
end

local function addRecentEvent(kind, text, payload)
    if type(saved.recentEvents) ~= "table" then
        saved.recentEvents = {}
    end

    table.insert(saved.recentEvents, 1, {
        ts = time(),
        kind = kind,
        text = text,
        payload = payload,
    })

    while #saved.recentEvents > MAX_RECENT_EVENTS do
        table.remove(saved.recentEvents)
    end

    if transparencyPanel and transparencyPanel:IsShown() then
        transparencyPanel:Refresh()
    end
    if leaderboardFrame and leaderboardFrame:IsShown() then
        refreshLeaderboardPopup()
    end
    if lootSessionFrame and lootSessionFrame:IsShown() then
        refreshLootSessionWindow()
    end
end

showLootUiWindows = function()
    if not canAutoOpenLootWindows() then
        return
    end

    if leaderboardFrame and saved.showCompactLeaderboard then
        leaderboardFrame:Show()
        refreshLeaderboardPopup()
    end

    local panel = lootSessionFrame or (createLootSessionWindow and createLootSessionWindow() or nil)
    if panel then
        panel:Show()
        refreshLootSessionWindow()
    end
end

local function sanitizeField(value)
    value = tostring(value or "")
    return string.gsub(value, COMM_DELIM, " ")
end

local function splitByDelim(message)
    local parts = {}
    local pattern = "([^" .. COMM_DELIM .. "]+)"
    for part in string.gmatch(message or "", pattern) do
        table.insert(parts, part)
    end
    return parts
end

isInRaid = function()
    return IsInGroup() and IsInRaid()
end

isRaidLeader = function()
    return UnitIsGroupLeader("player") == true
end

local function getCommChannel()
    if isInRaid() then
        return "RAID"
    end
    return nil
end

local function sendComm(messageType, ...)
    local channel = getCommChannel()
    if not channel then return false end

    if not C_ChatInfo or not C_ChatInfo.SendAddonMessage then
        return false
    end

    local payload = { messageType }
    local args = { ... }
    for i = 1, #args do
        payload[#payload + 1] = sanitizeField(args[i])
    end

    local finalMessage = table.concat(payload, COMM_DELIM)
    C_ChatInfo.SendAddonMessage(COMM_PREFIX, finalMessage, channel)
    return true
end

local function normalizeRealm(realm)
    realm = tostring(realm or "")
    realm = string.gsub(realm, "%s+", "")
    realm = string.lower(realm)
    realm = string.gsub(realm, "['’`%-]", "")
    return realm
end

local function normalizeName(name)
    name = tostring(name or "")
    name = string.lower(name)
    name = string.gsub(name, "%s+", "")
    return name
end

local function makePlayerKey(name, realm)
    local n = normalizeName(name)
    local r = normalizeRealm(realm)
    if r ~= "" then
        return n .. "-" .. r
    end
    return n
end

local function splitFullName(fullName)
    if not fullName then return "", "" end
    local name, realm = string.match(fullName, "^([^%-]+)%-(.+)$")
    if not name then
        return fullName, GetRealmName() or ""
    end
    return name, realm
end

local function buildCanonicalFullName(name, realm)
    name = tostring(name or "")
    realm = tostring(realm or "")
    if name == "" then
        return ""
    end
    if realm == "" then
        realm = GetRealmName() or ""
    end
    if realm ~= "" then
        return name .. "-" .. realm
    end
    return name
end

local function getUnitCanonicalFullName(unit)
    if not UnitExists(unit) then
        return nil
    end

    local fullName = GetUnitName(unit, true)
    local name, realm = splitFullName(fullName)

    if not realm or realm == "" then
        local unitName, unitRealm = UnitFullName(unit)
        name = unitName or name
        realm = unitRealm or realm
    end

    local canonical = buildCanonicalFullName(name, realm)
    if canonical == "" then
        return nil
    end
    return canonical
end

local function getSelfKey()
    local full = getUnitCanonicalFullName("player") or GetUnitName("player", true)
    local name, realm = splitFullName(full)
    return makePlayerKey(name, realm), full
end

local function getCurrentUtcTimestampString(ts)
    return date("!%Y-%m-%dT%H:%M:%SZ", ts or time())
end

local function isAuthorityKey(playerKey)
    return type(saved.authorityChain) == "table" and saved.authorityChain[playerKey] ~= nil
end

local function normalizeFullNameToKey(fullName)
    local name, realm = splitFullName(fullName)
    return makePlayerKey(name, realm)
end

local function getGroupLeaderKey()
    if not IsInGroup() then
        return nil
    end

    local function unitToKey(unit)
        local fullName = getUnitCanonicalFullName(unit) or GetUnitName(unit, true)
        if not fullName or fullName == "" then
            return nil
        end
        return normalizeFullNameToKey(fullName)
    end

    if IsInRaid() then
        for i = 1, GetNumGroupMembers() do
            local unit = "raid" .. i
            if UnitExists(unit) and UnitIsGroupLeader(unit) then
                return unitToKey(unit)
            end
        end
    else
        if UnitIsGroupLeader("player") then
            return select(1, getSelfKey())
        end
        for i = 1, GetNumSubgroupMembers() do
            local unit = "party" .. i
            if UnitExists(unit) and UnitIsGroupLeader(unit) then
                return unitToKey(unit)
            end
        end
    end

    return nil
end

local function isSenderGroupLeader(sender)
    local senderKey = normalizeFullNameToKey(sender)
    if senderKey == "" then
        return false
    end

    local leaderKey = getGroupLeaderKey()
    return leaderKey ~= nil and senderKey == leaderKey
end

local function getTargetPlayerFullName()
    if not UnitExists("target") or not UnitIsPlayer("target") then
        return nil
    end

    local fullName = getUnitCanonicalFullName("target") or GetUnitName("target", true)
    if not fullName or fullName == "" then
        return nil
    end

    return fullName
end

local function addAuthorityMember(fullName)
    local name, realm = splitFullName(fullName)
    local canonicalFullName = buildCanonicalFullName(name, realm)
    local normalizedKey = normalizeFullNameToKey(canonicalFullName)
    if normalizedKey == "" then
        return false
    end

    saved.authorityChain = saved.authorityChain or {}
    saved.authorityChain[normalizedKey] = canonicalFullName
    touchPersistentState("authority_add")
    return true, normalizedKey
end

local function removeAuthorityMember(fullName)
    local normalizedKey = normalizeFullNameToKey(fullName)
    if normalizedKey == "" or not isAuthorityKey(normalizedKey) then
        return false
    end

    saved.authorityChain[normalizedKey] = nil
    touchPersistentState("authority_remove")
    return true, normalizedKey
end

local function ensureLeaderAuthority()
    if isRaidLeader() then
        addAuthorityMember(getUnitCanonicalFullName("player") or GetUnitName("player", true) or "")
    end
end

local function getAuthorityStatusRecord(playerKey)
    if not playerKey or playerKey == "" then
        return nil
    end

    return authorityStatusInbox[playerKey]
end

local function getAuthorityFreshnessState(playerKey)
    local record = getAuthorityStatusRecord(playerKey)
    if not record or not record.verifiedAt then
        return "offline", nil, record
    end

    local age = math.max(0, time() - (tonumber(record.verifiedAt) or 0))
    if age <= AUTHORITY_FRESH_SECONDS then
        return "fresh", age, record
    end

    return "stale", age, record
end

local function getAuthorityRows()
    local rows = {}

    for key, fullName in pairs(saved.authorityChain or {}) do
        local state, ageSeconds, record = getAuthorityFreshnessState(key)
        rows[#rows + 1] = {
            key = key,
            fullName = fullName or key,
            state = state,
            ageSeconds = ageSeconds,
            updatedAt = record and record.updatedAt or "",
        }
    end

    table.sort(rows, function(a, b)
        if a.state == b.state then
            return (a.fullName or a.key) < (b.fullName or b.key)
        end

        local order = { fresh = 1, stale = 2, offline = 3 }
        return (order[a.state] or 99) < (order[b.state] or 99)
    end)

    return rows
end

local function isFreshAuthoritySender(sender, senderIsLeader, senderIsAuthority)
    if senderIsLeader then
        return true
    end

    if not senderIsAuthority then
        return false
    end

    local senderKey = normalizeFullNameToKey(sender)
    local state = getAuthorityFreshnessState(senderKey)
    return state == "fresh"
end

local function formatAuthorityStateText(state, ageSeconds)
    if state == "fresh" then
        return string.format("Fresh %ss", tostring(ageSeconds or 0)), 0.15, 1.0, 0.25
    end
    if state == "stale" then
        return string.format("Stale %ss", tostring(ageSeconds or 0)), 1.0, 0.82, 0.15
    end
    return "Offline", 1.0, 0.35, 0.35
end

local function getBestAuthoritySource()
    local best = nil
    local selfKey, selfFullName = getSelfKey()

    if isAuthorityKey(selfKey) then
        best = {
            key = selfKey,
            fullName = selfFullName,
            timestamp = tonumber(saved.stateTimestamp) or 0,
        }
    end

    for _, candidate in pairs(authorityStatusInbox) do
        if candidate and candidate.key and isAuthorityKey(candidate.key) then
            if (not best)
                or (candidate.timestamp or 0) > (best.timestamp or 0)
                or ((candidate.timestamp or 0) == (best.timestamp or 0) and (candidate.key or "") < (best.key or "")) then
                best = candidate
            end
        end
    end

    return best
end

local function scheduleAuthoritySnapshotEvaluation()
    if not shouldBroadcastAuthorityState() then
        return
    end

    C_Timer.After(0.35, function()
        if not saved or not isInRaid() then
            return
        end

        local selfKey = select(1, getSelfKey())
        local bestSource = getBestAuthoritySource()
        if (not bestSource) or bestSource.key == selfKey then
            sendSnapshot()
        end
    end)
end

shouldBroadcastAuthorityState = function()
    local selfKey = select(1, getSelfKey())
    return isInRaid() and (isRaidLeader() or isAuthorityKey(selfKey))
end

touchPersistentState = function(reason)
    local selfKey, selfFullName = getSelfKey()
    saved.stateTimestamp = time()
    saved.stateUpdatedAt = getCurrentUtcTimestampString(saved.stateTimestamp)
    saved.stateOwnerKey = selfKey
    saved.stateOwnerName = selfFullName
    saved.stateReason = reason or saved.stateReason or ""

    authorityStatusInbox[selfKey] = {
        key = selfKey,
        fullName = selfFullName,
        timestamp = saved.stateTimestamp,
        updatedAt = saved.stateUpdatedAt,
        verifiedAt = time(),
    }

    if shouldBroadcastAuthorityState() then
        sendComm("AUTH_STATUS", selfKey, selfFullName, tostring(saved.stateTimestamp), saved.stateUpdatedAt)
    end
end

local function weightedCategory(category)
    return category == "upgrade" or category == "bis" or category == "set2" or category == "set4"
end

local function getPlayerMultiplier(playerKey)
    if type(saved.weights) ~= "table" then
        saved.weights = {}
    end

    local value = saved.weights[playerKey]
    if type(value) ~= "number" then
        value = 1.0
        saved.weights[playerKey] = value
    end

    local floor = tonumber(saved.minMultiplier) or 0.40
    if value < floor then value = floor end
    if value > 1.0 then value = 1.0 end
    saved.weights[playerKey] = value

    return value
end

local function setPlayerMultiplier(playerKey, multiplier)
    if type(saved.weights) ~= "table" then
        saved.weights = {}
    end

    local floor = tonumber(saved.minMultiplier) or 0.40
    if multiplier < floor then multiplier = floor end
    if multiplier > 1.0 then multiplier = 1.0 end

    saved.weights[playerKey] = multiplier
end

local function incrementWinCount(playerKey)
    if type(saved.wins) ~= "table" then
        saved.wins = {}
    end
    local count = tonumber(saved.wins[playerKey]) or 0
    saved.wins[playerKey] = count + 1
end

local function applyWinPenalty(playerKey, actorName)
    local penalty = tonumber(saved.penaltyOnWin) or 0.30
    local current = getPlayerMultiplier(playerKey)
    local nextValue = current - penalty
    setPlayerMultiplier(playerKey, nextValue)
    incrementWinCount(playerKey)

    local displayName = (saved.roster[playerKey] and saved.roster[playerKey].displayName) or playerKey
    addRecentEvent("win", string.format("%s received penalty (%.0f%%). New multiplier: %.2f", displayName, penalty * 100, getPlayerMultiplier(playerKey)), {
        playerKey = playerKey,
        actor = actorName,
    })
    touchPersistentState("award")
end

local function applyBossRecovery(bossName, bossId)
    local recovery = tonumber(saved.recoveryPerBoss) or 0.05
    if recovery <= 0 then return false end

    local dedupeId = tostring(bossId or 0)
    local dedupeName = tostring(bossName or "unknown")
    local dedupeKey = dedupeId .. "|" .. dedupeName
    local now = time()
    local lastAppliedAt = recentBossRecovery[dedupeKey]
    if lastAppliedAt and (now - lastAppliedAt) < 20 then
        return false
    end
    recentBossRecovery[dedupeKey] = now

    if type(saved.weights) ~= "table" then
        saved.weights = {}
    end

    for key, value in pairs(saved.weights) do
        local v = tonumber(value) or 1.0
        if v < 1.0 then
            setPlayerMultiplier(key, v + recovery)
        end
    end

    saved.lastAppliedBossId = bossId
    addRecentEvent("recovery", string.format("Boss recovery applied (+%.0f%%) from %s", recovery * 100, bossName or "Unknown Boss"), {
        bossId = bossId,
        bossName = bossName,
    })
    touchPersistentState("boss_recovery")
    return true
end

local function chooseChatType()
    if IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then
        return "INSTANCE_CHAT"
    elseif IsInRaid() then
        return "RAID"
    elseif IsInGroup() then
        return "PARTY"
    end
    return "SAY"
end

local function computeEffectiveMax(baseMax, category, playerKey)
    if not saved.fairnessEnabled then
        return baseMax, 1.0
    end

    if not weightedCategory(category) then
        return baseMax, 1.0
    end

    local multiplier = getPlayerMultiplier(playerKey)
    local computed = math.floor(baseMax * multiplier + 0.0001)
    if computed < 1 then computed = 1 end
    if computed > baseMax then computed = baseMax end
    return computed, multiplier
end

local function serializeMap(map)
    local chunks = {}
    for key, value in pairs(map or {}) do
        chunks[#chunks + 1] = key .. "=" .. tostring(value)
    end
    table.sort(chunks)
    return table.concat(chunks, ";")
end

local function deserializeMap(serialized)
    local out = {}
    for pair in string.gmatch(serialized or "", "([^;]+)") do
        local key, value = string.match(pair, "^([^=]+)=(.+)$")
        if key and value then
            out[key] = value
        end
    end
    return out
end

local function sanitizeSnapshotToken(value)
    value = tostring(value or "")
    value = string.gsub(value, "|", "{PIPE}")
    return string.gsub(value, "[\n\r]", " ")
end

local function restoreSnapshotToken(value)
    value = tostring(value or "")
    return string.gsub(value, "{PIPE}", "|")
end

local function buildSnapshotPayload()
    local lines = {}

    lines[#lines + 1] = string.format(
        "META|%s|%s|%s|%s|%s",
        tostring(saved.lastSyncSeq or 0),
        tostring(saved.stateTimestamp or 0),
        sanitizeSnapshotToken(saved.stateUpdatedAt or ""),
        sanitizeSnapshotToken(saved.stateOwnerKey or ""),
        sanitizeSnapshotToken(saved.stateOwnerName or "")
    )
    lines[#lines + 1] = string.format(
        "CFG|%s|%.4f|%.4f|%.4f|%s|%s|%s|%s|%s|%s",
        saved.fairnessEnabled and "1" or "0",
        saved.penaltyOnWin or 0.30,
        saved.recoveryPerBoss or 0.05,
        saved.minMultiplier or 0.40,
        saved.raidActive and "1" or "0",
        saved.autoPassNonLeader and "1" or "0",
        tostring(saved.leaderAutoLootMode or "need_or_greed"),
        saved.showCompactLeaderboard and "1" or "0",
        saved.testMode and "1" or "0",
        saved.categoryPriorityMode and "1" or "0"
    )
    lines[#lines + 1] = "WEIGHTS|" .. serializeMap(saved.weights or {})
    lines[#lines + 1] = "WINS|" .. serializeMap(saved.wins or {})

    local rosterParts = {}
    for key, item in pairs(saved.roster or {}) do
        local displayName = item.displayName or key
        rosterParts[#rosterParts + 1] = key .. "=" .. string.gsub(displayName, "[;=|]", "")
    end
    table.sort(rosterParts)
    lines[#lines + 1] = "ROSTER|" .. table.concat(rosterParts, ";")

    local authorityParts = {}
    for key, fullName in pairs(saved.authorityChain or {}) do
        authorityParts[#authorityParts + 1] = key .. "=" .. sanitizeSnapshotToken(fullName)
    end
    table.sort(authorityParts)
    lines[#lines + 1] = "AUTH|" .. table.concat(authorityParts, ";")

    for i = 1, math.min(80, #(saved.lootSession or {})) do
        local row = saved.lootSession[i]
        if row then
            lines[#lines + 1] = string.format(
                "SESSION|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s",
                sanitizeSnapshotToken(row.id or i),
                tostring(tonumber(row.ts) or time()),
                tostring(tonumber(row.expiresAt) or (time() + 120)),
                tostring(tonumber(row.eligibleCount) or 1),
                sanitizeSnapshotToken(row.state or "open"),
                sanitizeSnapshotToken(row.winner or "Pending"),
                sanitizeSnapshotToken(row.itemName or "Unknown Item"),
                sanitizeSnapshotToken(row.itemLink or ""),
                sanitizeSnapshotToken(row.topPlayer or ""),
                tostring(tonumber(row.topRoll) or 0),
                sanitizeSnapshotToken(row.topCategory or ""),
                sanitizeSnapshotToken(row.source or "loot")
            )

            lines[#lines + 1] = string.format(
                "SESSIONTIE|%s|%s|%s|%s|%s|%s|%s|%s|%s|%s",
                sanitizeSnapshotToken(row.id or i),
                row.rolloffActive and "1" or "0",
                tostring(tonumber(row.rolloffRound) or 0),
                tostring(tonumber(row.rolloffExpiresAt) or 0),
                sanitizeSnapshotToken(serializeKeyList(row.tiePlayerKeys or {})),
                sanitizeSnapshotToken(serializeKeyList(row.rolloffParticipants or {})),
                sanitizeSnapshotToken(row.rolloffWinnerKey or ""),
                sanitizeSnapshotToken(row.rolloffWinnerName or ""),
                tostring(tonumber(row.rolloffWinnerRoll) or 0),
                sanitizeSnapshotToken(row.lastDecision or "")
            )

            for playerKey, roll in pairs(row.rolls or {}) do
                if type(roll) == "table" then
                    lines[#lines + 1] = string.format(
                        "SESSIONROLL|%s|%s|%s|%s|%s|%s|%s|%s|%s",
                        sanitizeSnapshotToken(row.id or i),
                        sanitizeSnapshotToken(playerKey),
                        sanitizeSnapshotToken(roll.playerName or playerKey),
                        sanitizeSnapshotToken(roll.category or ""),
                        sanitizeSnapshotToken(roll.label or ""),
                        tostring(tonumber(roll.rolled) or 0),
                        tostring(tonumber(roll.max) or 0),
                        tostring(tonumber(roll.multiplier) or 1),
                        sanitizeSnapshotToken(roll.status or "active")
                    )
                end
            end

            for playerKey, roll in pairs(row.rolloffRolls or {}) do
                if type(roll) == "table" then
                    lines[#lines + 1] = string.format(
                        "ROLLOFFROLL|%s|%s|%s|%s|%s|%s|%s",
                        sanitizeSnapshotToken(row.id or i),
                        tostring(tonumber(row.rolloffRound) or 0),
                        sanitizeSnapshotToken(playerKey),
                        sanitizeSnapshotToken(roll.playerName or playerKey),
                        tostring(tonumber(roll.rolled) or 0),
                        tostring(tonumber(roll.max) or 100),
                        sanitizeSnapshotToken(roll.status or "active")
                    )
                end
            end
        end
    end

    return table.concat(lines, "\n")
end

local function applySnapshotPayload(payload)
    if type(payload) ~= "string" or payload == "" then
        return false, "empty payload"
    end

    local incoming = {
        cfg = nil,
        meta = nil,
        weights = {},
        wins = {},
        roster = {},
        authorityChain = {},
        session = {},
        sessionById = {},
    }

    for line in string.gmatch(payload, "([^\n]+)") do
        local kind, rest = string.match(line, "^(%u+)%|(.*)$")
        if kind == "META" then
            local syncSeq, stateTimestamp, stateUpdatedAt, stateOwnerKey, stateOwnerName = string.match(rest, "^([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)$")
            incoming.meta = {
                lastSyncSeq = tonumber(syncSeq) or 0,
                stateTimestamp = tonumber(stateTimestamp) or 0,
                stateUpdatedAt = restoreSnapshotToken(stateUpdatedAt or ""),
                stateOwnerKey = restoreSnapshotToken(stateOwnerKey or ""),
                stateOwnerName = restoreSnapshotToken(stateOwnerName or ""),
            }
        elseif kind == "CFG" then
            local cfgParts = {}
            for value in string.gmatch((rest or "") .. "|", "([^|]*)|") do
                cfgParts[#cfgParts + 1] = value
            end
            incoming.cfg = {
                fairnessEnabled = cfgParts[1] == "1",
                penaltyOnWin = tonumber(cfgParts[2]) or 0.30,
                recoveryPerBoss = tonumber(cfgParts[3]) or 0.05,
                minMultiplier = tonumber(cfgParts[4]) or 0.40,
                raidActive = cfgParts[5] == "1",
                autoPassNonLeader = cfgParts[6] == "1",
                leaderAutoLootMode = (cfgParts[7] and cfgParts[7] ~= "") and cfgParts[7] or "need_or_greed",
                showCompactLeaderboard = cfgParts[8] == "1",
                testMode = cfgParts[9] == "1",
                categoryPriorityMode = cfgParts[10] == "1",
            }
        elseif kind == "WEIGHTS" then
            local raw = deserializeMap(rest)
            for key, value in pairs(raw) do
                incoming.weights[key] = tonumber(value) or 1.0
            end
        elseif kind == "WINS" then
            local raw = deserializeMap(rest)
            for key, value in pairs(raw) do
                incoming.wins[key] = tonumber(value) or 0
            end
        elseif kind == "ROSTER" then
            for pair in string.gmatch(rest or "", "([^;]+)") do
                local key, value = string.match(pair, "^([^=]+)=(.+)$")
                if key and value then
                    incoming.roster[key] = { displayName = value }
                end
            end
        elseif kind == "AUTH" then
            for pair in string.gmatch(rest or "", "([^;]+)") do
                local key, value = string.match(pair, "^([^=]+)=(.+)$")
                if key and value then
                    incoming.authorityChain[key] = restoreSnapshotToken(value)
                end
            end
        elseif kind == "SESSION" then
            local id, ts, expiresAt, eligibleCount, state, winner, itemName, itemLink, topPlayer, topRoll, topCategory, source = string.match(rest, "^([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)$")
            if id and itemName then
                local row = {
                    id = id,
                    ts = tonumber(ts) or time(),
                    expiresAt = tonumber(expiresAt) or (time() + 120),
                    eligibleCount = tonumber(eligibleCount) or 1,
                    state = restoreSnapshotToken((state ~= "" and state) or "open"),
                    winner = restoreSnapshotToken((winner ~= "" and winner) or "Pending"),
                    itemName = restoreSnapshotToken(itemName),
                    itemLink = (itemLink ~= "" and restoreSnapshotToken(itemLink) or nil),
                    topPlayer = (topPlayer ~= "" and restoreSnapshotToken(topPlayer)) or nil,
                    topRoll = tonumber(topRoll) or nil,
                    topCategory = (topCategory ~= "" and restoreSnapshotToken(topCategory)) or nil,
                    source = restoreSnapshotToken((source ~= "" and source) or "sync"),
                    rolls = {},
                    tiePlayerKeys = {},
                    rolloffParticipants = {},
                    rolloffRolls = {},
                }
                incoming.session[#incoming.session + 1] = row
                incoming.sessionById[id] = row
            end
        elseif kind == "SESSIONTIE" then
            local id, rolloffActive, rolloffRound, rolloffExpiresAt, tieKeys, rolloffParticipants, winnerKey, winnerName, winnerRoll, lastDecision = string.match(rest, "^([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)$")
            local row = id and incoming.sessionById[id] or nil
            if row then
                row.rolloffActive = rolloffActive == "1"
                row.rolloffRound = tonumber(rolloffRound) or 0
                row.rolloffExpiresAt = tonumber(rolloffExpiresAt) or 0
                row.tiePlayerKeys = deserializeKeyList(restoreSnapshotToken(tieKeys or ""))
                row.rolloffParticipants = deserializeKeyList(restoreSnapshotToken(rolloffParticipants or ""))
                row.rolloffWinnerKey = (winnerKey ~= "" and restoreSnapshotToken(winnerKey)) or nil
                row.rolloffWinnerName = (winnerName ~= "" and restoreSnapshotToken(winnerName)) or nil
                row.rolloffWinnerRoll = tonumber(winnerRoll) or nil
                row.lastDecision = restoreSnapshotToken(lastDecision or "")
            end
        elseif kind == "SESSIONROLL" then
            local itemId, playerKey, playerName, category, label, rolled, maxV, mult, rollStatus = string.match(rest, "^([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)$")
            local row = itemId and incoming.sessionById[itemId] or nil
            if row and playerKey and playerKey ~= "" then
                row.rolls[playerKey] = {
                    playerName = restoreSnapshotToken(playerName),
                    category = restoreSnapshotToken(category),
                    label = restoreSnapshotToken(label),
                    rolled = tonumber(rolled) or 0,
                    max = tonumber(maxV) or 0,
                    multiplier = tonumber(mult) or 1.0,
                    status = restoreSnapshotToken((rollStatus ~= "" and rollStatus) or "active"),
                    ts = time(),
                }
            end
        elseif kind == "ROLLOFFROLL" then
            local itemId, round, playerKey, playerName, rolled, maxV, rollStatus = string.match(rest, "^([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)|([^|]*)$")
            local row = itemId and incoming.sessionById[itemId] or nil
            if row and playerKey and playerKey ~= "" then
                row.rolloffRound = tonumber(round) or row.rolloffRound or 0
                row.rolloffRolls[playerKey] = {
                    playerName = restoreSnapshotToken(playerName),
                    rolled = tonumber(rolled) or 0,
                    max = tonumber(maxV) or 100,
                    status = restoreSnapshotToken((rollStatus ~= "" and rollStatus) or "active"),
                    ts = time(),
                }
            end
        end
    end

    if incoming.cfg then
        saved.fairnessEnabled = incoming.cfg.fairnessEnabled
        saved.penaltyOnWin = incoming.cfg.penaltyOnWin
        saved.recoveryPerBoss = incoming.cfg.recoveryPerBoss
        saved.minMultiplier = incoming.cfg.minMultiplier
        saved.raidActive = incoming.cfg.raidActive
        saved.autoPassNonLeader = incoming.cfg.autoPassNonLeader
        saved.leaderAutoLootMode = incoming.cfg.leaderAutoLootMode
        saved.showCompactLeaderboard = incoming.cfg.showCompactLeaderboard
        saved.testMode = incoming.cfg.testMode
        saved.categoryPriorityMode = incoming.cfg.categoryPriorityMode
    end

    if incoming.meta then
        saved.lastSyncSeq = incoming.meta.lastSyncSeq
        saved.stateTimestamp = incoming.meta.stateTimestamp
        saved.stateUpdatedAt = incoming.meta.stateUpdatedAt
        saved.stateOwnerKey = incoming.meta.stateOwnerKey
        saved.stateOwnerName = incoming.meta.stateOwnerName
    end

    saved.weights = incoming.weights
    saved.wins = incoming.wins
    saved.authorityChain = incoming.authorityChain

    for key, item in pairs(incoming.roster) do
        saved.roster[key] = saved.roster[key] or {}
        saved.roster[key].displayName = item.displayName
    end

    saved.lootSession = incoming.session

    addRecentEvent("sync", "State snapshot applied from Raid Leader", nil)

    if contextMenu and contextMenu:IsShown() then
        contextMenu:RefreshFairnessWidgets()
    end

    if leaderboardFrame then
        leaderboardFrame:SetShown(saved.showCompactLeaderboard and saved.raidActive and isInRaid())
        refreshLeaderboardPopup()
    end

    refreshLootSessionWindow()

    return true
end

sendSnapshot = function()
    local selfKey = select(1, getSelfKey())
    if not (isRaidLeader() or isAuthorityKey(selfKey)) then return end

    local channel = getCommChannel()
    if not channel then return end

    saved.lastSyncSeq = (saved.lastSyncSeq or 0) + 1
    local seq = saved.lastSyncSeq
    local payload = buildSnapshotPayload()

    local totalChunks = math.ceil(string.len(payload) / SNAPSHOT_CHUNK_SIZE)
    sendComm("STATE_BEGIN", seq, totalChunks)

    local index = 1
    for i = 1, totalChunks do
        local slice = string.sub(payload, index, index + SNAPSHOT_CHUNK_SIZE - 1)
        index = index + SNAPSHOT_CHUNK_SIZE
        sendComm("STATE_CHUNK", seq, i, totalChunks, slice)
    end

    sendComm("STATE_END", seq)
    addRecentEvent("sync", "Raid Leader broadcasted full state snapshot", nil)
end

local function requestSnapshot()
    local selfKey, selfFullName = getSelfKey()
    sendComm("STATE_REQUEST", selfKey or "unknown", selfFullName or "unknown")
end

local function broadcastAuthorityStatus()
    local selfKey, selfFullName = getSelfKey()
    if isRaidLeader() then
        addAuthorityMember(selfFullName or "")
    end
    authorityStatusInbox[selfKey] = {
        key = selfKey,
        fullName = selfFullName or "",
        timestamp = tonumber(saved.stateTimestamp) or 0,
        updatedAt = saved.stateUpdatedAt or "",
        verifiedAt = time(),
    }
    if isInRaid() and (isRaidLeader() or isAuthorityKey(selfKey)) then
        sendComm("AUTH_STATUS", selfKey, selfFullName or "", tostring(saved.stateTimestamp or 0), saved.stateUpdatedAt or "")
    end
end

local function refreshTransparencyPanel()
    if not transparencyPanel or not transparencyPanel.lines then return end

    local output = {}
    local maxPenaltyRows = transparencyPanel.maxPenaltyRows or 10
    local maxEventRows = transparencyPanel.maxEventRows or 8
    local bestSource = getBestAuthoritySource()
    local bestState, bestAge = bestSource and getAuthorityFreshnessState(bestSource.key) or "offline", nil
    local authorityName = bestSource and (bestSource.fullName or bestSource.key) or (isRaidLeader() and "You (Raid Leader)" or "No verified authority")
    local authorityStateText = bestSource and select(1, formatAuthorityStateText(bestState, bestAge)) or "Offline"

    output[#output + 1] = string.format("Authority: %s", authorityName)
    output[#output + 1] = string.format("Source State: %s", authorityStateText)
    output[#output + 1] = string.format("Fairness: %s", saved.fairnessEnabled and "Enabled" or "Disabled")
    output[#output + 1] = string.format("Same-item Priority: %s", saved.categoryPriorityMode and "Category first" or "Highest roll")
    output[#output + 1] = string.format("Penalty %.0f%% | Recovery %.0f%% | Min %.0f%%", (saved.penaltyOnWin or 0.30) * 100, (saved.recoveryPerBoss or 0.05) * 100, (saved.minMultiplier or 0.40) * 100)
    output[#output + 1] = ""

    local weightedList = {}
    for key, mult in pairs(saved.weights or {}) do
        local displayName = (saved.roster[key] and saved.roster[key].displayName) or key
        weightedList[#weightedList + 1] = {
            key = key,
            displayName = displayName,
            mult = tonumber(mult) or 1.0,
            wins = tonumber((saved.wins or {})[key]) or 0,
        }
    end

    table.sort(weightedList, function(a, b)
        if a.mult == b.mult then
            return a.displayName < b.displayName
        end
        return a.mult < b.mult
    end)

    output[#output + 1] = "Penalties:"
    local shown = 0
    for _, row in ipairs(weightedList) do
        if shown >= maxPenaltyRows then break end
        output[#output + 1] = string.format("- %s: %.2f (%d wins)", row.displayName, row.mult, row.wins)
        shown = shown + 1
    end
    if shown == 0 then
        output[#output + 1] = "- No tracked penalties yet"
    end

    output[#output + 1] = ""
    output[#output + 1] = "Recent Events:"
    for i = 1, math.min(maxEventRows, #(saved.recentEvents or {})) do
        local event = saved.recentEvents[i]
        if event then
            local stamp = date("%H:%M:%S", event.ts or time())
            output[#output + 1] = string.format("[%s] %s", stamp, event.text or "")
        end
    end

    for i = 1, #transparencyPanel.lines do
        transparencyPanel.lines[i]:SetText(output[i] or "")
    end
end

local function ensureLootSession()
    if type(saved.lootSession) ~= "table" then
        saved.lootSession = {}
    end
end

local updateSessionRollSummary

local function countTableEntries(map)
    local count = 0
    for _ in pairs(map or {}) do
        count = count + 1
    end
    return count
end

local function collectCurrentGroupPlayers()
    local players = {}

    local function addUnit(unit)
        if not UnitExists(unit) then return end
        local fullName = getUnitCanonicalFullName(unit)
        if not fullName or fullName == "" then return end
        local name, realm = splitFullName(fullName)
        local key = makePlayerKey(name, realm)
        if key ~= "" and not players[key] then
            players[key] = {
                key = key,
                fullName = fullName,
            }
        end
    end

    if IsInRaid() then
        for i = 1, GetNumGroupMembers() do
            addUnit("raid" .. i)
        end
    elseif IsInGroup() then
        addUnit("player")
        for i = 1, GetNumSubgroupMembers() do
            addUnit("party" .. i)
        end
    else
        addUnit("player")
    end

    return players
end

local function getDisplayNameForKey(playerKey, fullName)
    local rosterEntry = saved.roster and saved.roster[playerKey]
    if rosterEntry and rosterEntry.displayName and rosterEntry.displayName ~= "" then
        return rosterEntry.displayName
    end

    return string.match(fullName or "", "^([^%-]+)") or fullName or playerKey
end

local function syncCurrentRaidMembersToSaved()
    if raidRosterCache and not raidRosterCacheDirty then
        return raidRosterCache
    end

    local players = collectCurrentGroupPlayers()

    for playerKey, info in pairs(players) do
        local name, realm = splitFullName(info.fullName)
        saved.roster[playerKey] = saved.roster[playerKey] or {}
        if not saved.roster[playerKey].displayName or saved.roster[playerKey].displayName == "" then
            saved.roster[playerKey].displayName = getDisplayNameForKey(playerKey, info.fullName)
        end
        saved.roster[playerKey].fullName = info.fullName
        saved.roster[playerKey].name = name
        saved.roster[playerKey].realm = realm
        if saved.weights[playerKey] == nil then
            saved.weights[playerKey] = 1.0
        end
        if saved.wins[playerKey] == nil then
            saved.wins[playerKey] = 0
        end
    end

    raidRosterCache = players
    raidRosterCacheDirty = false
    return players
end

local function getEligibleSessionRollerCount()
    local players = syncCurrentRaidMembersToSaved()
    local count = countTableEntries(players)
    if count < 1 then
        count = 1
    end
    return count, players
end

local function buildLeaderboardRows()
    local rows = {}
    local currentRaid = saved.raidActive and isInRaid() and syncCurrentRaidMembersToSaved() or nil

    if currentRaid then
        for playerKey, info in pairs(currentRaid) do
            rows[#rows + 1] = {
                key = playerKey,
                display = getDisplayNameForKey(playerKey, info.fullName),
                multiplier = tonumber((saved.weights or {})[playerKey]) or 1.0,
                wins = tonumber((saved.wins or {})[playerKey]) or 0,
            }
        end
    else
        for key, mult in pairs(saved.weights or {}) do
            rows[#rows + 1] = {
                key = key,
                display = (saved.roster[key] and saved.roster[key].displayName) or key,
                multiplier = tonumber(mult) or 1.0,
                wins = tonumber((saved.wins or {})[key]) or 0,
            }
        end
    end

    table.sort(rows, function(a, b)
        if a.multiplier == b.multiplier then
            return a.display < b.display
        end
        return a.multiplier < b.multiplier
    end)

    return rows
end

local function escapeRosterTextField(value)
    value = tostring(value or "")
    value = string.gsub(value, "|", "/")
    value = string.gsub(value, "\r", " ")
    value = string.gsub(value, "\n", " ")
    return value
end

local function buildLiveRosterExportText()
    local players = syncCurrentRaidMembersToSaved()
    local ordered = {}

    for playerKey, info in pairs(players) do
        ordered[#ordered + 1] = {
            key = playerKey,
            fullName = info.fullName,
            displayName = getDisplayNameForKey(playerKey, info.fullName),
            multiplier = tonumber((saved.weights or {})[playerKey]) or 1.0,
            wins = tonumber((saved.wins or {})[playerKey]) or 0,
        }
    end

    table.sort(ordered, function(a, b)
        return a.displayName < b.displayName
    end)

    local lines = {}
    lines[#lines + 1] = "# DetentionRolls Roster Export"
    lines[#lines + 1] = "# exportedAt: " .. date("!%Y-%m-%dT%H:%M:%SZ")
    lines[#lines + 1] = "# currentRaidCount: " .. tostring(#ordered)
    lines[#lines + 1] = "displayName | fullName | rank | role | multiplier | wins | authority"

    for i, row in ipairs(ordered) do
        local rosterEntry = saved.roster[row.key] or {}
        lines[#lines + 1] = string.format(
            "%s | %s | %s | %s | %.2f | %d | %s",
            escapeRosterTextField(row.displayName),
            escapeRosterTextField(row.fullName or row.key),
            escapeRosterTextField(rosterEntry.rank or ""),
            escapeRosterTextField(rosterEntry.mainRole or ""),
            row.multiplier,
            row.wins,
            isAuthorityKey(row.key) and "yes" or "no"
        )
    end

    return table.concat(lines, "\n")
end

local function ensureSessionItemState(row)
    if type(row) ~= "table" then return nil end

    row.id = tostring(row.id or (time() .. "-" .. tostring(math.random(1000, 9999))))
    row.ts = tonumber(row.ts) or time()
    row.itemName = row.itemName or "Unknown Item"
    row.winner = row.winner or "Pending"
    row.state = row.state or "open"
    row.expiresAt = tonumber(row.expiresAt) or (row.ts + 120)
    if type(row.rolls) ~= "table" then
        row.rolls = {}
    end
    if row.lastDecision == nil then
        row.lastDecision = ""
    end
    if type(row.tiePlayerKeys) ~= "table" then
        row.tiePlayerKeys = {}
    end
    row.rolloffActive = row.rolloffActive == true
    row.rolloffRound = tonumber(row.rolloffRound) or 0
    row.rolloffExpiresAt = tonumber(row.rolloffExpiresAt) or 0
    if type(row.rolloffParticipants) ~= "table" then
        row.rolloffParticipants = {}
    end
    if type(row.rolloffRolls) ~= "table" then
        row.rolloffRolls = {}
    end
    row.rolloffWinnerKey = row.rolloffWinnerKey or nil
    row.rolloffWinnerName = row.rolloffWinnerName or nil
    row.rolloffWinnerRoll = tonumber(row.rolloffWinnerRoll) or nil
    row.rolloffCount = tonumber(row.rolloffCount) or 0
    row.rolloffReady = row.rolloffReady == true

    local eligibleCount = tonumber(row.eligibleCount) or 0
    if eligibleCount < 1 then
        eligibleCount = getEligibleSessionRollerCount()
    end
    row.eligibleCount = eligibleCount

    return row
end

local function getSessionItemClassification(row)
    row = ensureSessionItemState(row)
    if not row then return "Unknown" end

    if row.itemTypeText and row._itemTypeLink == row.itemLink and row._itemTypeName == row.itemName then
        return row.itemTypeText
    end

    local itemName = tostring(row.itemName or "")
    local lowerName = string.lower(itemName)
    local itemTypeText = nil

    if lowerName:find("vanquisher", 1, true) or lowerName:find("vanquished", 1, true) or lowerName:find("conqueror", 1, true) or lowerName:find("protector", 1, true) then
        itemTypeText = "Tier Token"
    elseif row.itemLink and C_Item and C_Item.GetItemInfoInstant then
        local _, itemType, itemSubType, equipLoc = C_Item.GetItemInfoInstant(row.itemLink)
        if itemType and itemSubType and itemSubType ~= "" then
            itemTypeText = string.format("%s: %s", itemType, itemSubType)
        elseif itemType then
            itemTypeText = itemType
        elseif equipLoc and equipLoc ~= "" then
            itemTypeText = equipLoc
        end
    end

    if not itemTypeText or itemTypeText == "" then
        itemTypeText = "Unknown Item"
    end

    row.itemTypeText = itemTypeText
    row._itemTypeLink = row.itemLink
    row._itemTypeName = row.itemName
    return itemTypeText
end

local function getSessionTopRollEntry(row)
    row = updateSessionRollSummary(row)
    if not row then
        return nil, nil
    end

    local winnerKey = row.rolloffWinnerKey or row.topPlayerKey
    if not winnerKey then
        return nil, nil
    end

    return winnerKey, row.rolls and row.rolls[winnerKey] or nil
end

local function getSessionItemById(itemId)
    ensureLootSession()
    local targetId = tostring(itemId or "")
    if targetId == "" then return nil end

    for i = 1, #saved.lootSession do
        local row = ensureSessionItemState(saved.lootSession[i])
        if row and row.id == targetId then
            return row, i
        end
    end

    return nil
end

local function normalizeRollCategory(category, label)
    local normalized = string.lower(tostring(category or ""))
    if CATEGORY_PRIORITY_ORDER[normalized] then
        return normalized
    end

    local normalizedLabel = string.lower(tostring(label or ""))
    if normalizedLabel == "tmog" or normalizedLabel:find("transmog", 1, true) then
        return "tmog"
    end
    if normalizedLabel:find("offspec", 1, true) then
        return "offspec"
    end
    if normalizedLabel == "upgrade" then
        return "upgrade"
    end
    if normalizedLabel == "bis" then
        return "bis"
    end
    if normalizedLabel:find("2%-piece") or normalizedLabel:find("2 piece", 1, true) then
        return "set2"
    end
    if normalizedLabel:find("4%-piece") or normalizedLabel:find("4 piece", 1, true) then
        return "set4"
    end

    return normalized
end

local function getCategoryPriority(category, label)
    local normalized = normalizeRollCategory(category, label)
    return CATEGORY_PRIORITY_ORDER[normalized] or 0
end

local function copyList(list)
    local out = {}
    for i = 1, #(list or {}) do
        out[i] = list[i]
    end
    return out
end

local function listContains(list, value)
    for i = 1, #(list or {}) do
        if list[i] == value then
            return true
        end
    end
    return false
end

serializeKeyList = function(list)
    local parts = {}
    for i = 1, #(list or {}) do
        local value = tostring(list[i] or "")
        if value ~= "" then
            parts[#parts + 1] = value
        end
    end
    return table.concat(parts, ",")
end

deserializeKeyList = function(serialized)
    local list = {}
    for value in string.gmatch(tostring(serialized or ""), "([^,]+)") do
        if value ~= "" then
            list[#list + 1] = value
        end
    end
    return list
end

local function summarizeSessionRolls(rollMap, usePriority, allowedKeys)
    local topRoll
    local topPlayer
    local topPlayerKey
    local topCategory
    local topPriority
    local contenders = {}
    local rollCount = 0

    for playerKey, roll in pairs(rollMap or {}) do
        local isAllowed = not allowedKeys or allowedKeys[playerKey]
        if isAllowed and type(roll) == "table" and roll.status ~= "denied" then
            rollCount = rollCount + 1

            local rolled = tonumber(roll.rolled) or 0
            local playerName = roll.playerName or playerKey
            local categoryLabel = roll.label or roll.category or "Roll"
            local categoryPriority = usePriority and getCategoryPriority(roll.category, roll.label) or 0

            if not topRoll then
                topRoll = rolled
                topPlayer = playerName
                topPlayerKey = playerKey
                topCategory = categoryLabel
                topPriority = categoryPriority
                contenders = { playerKey }
            else
                local betterPriority = usePriority and categoryPriority > (topPriority or 0)
                local samePriority = (not usePriority) or categoryPriority == (topPriority or 0)

                if betterPriority then
                    topRoll = rolled
                    topPlayer = playerName
                    topPlayerKey = playerKey
                    topCategory = categoryLabel
                    topPriority = categoryPriority
                    contenders = { playerKey }
                elseif samePriority and rolled > (topRoll or 0) then
                    topRoll = rolled
                    topPlayer = playerName
                    topPlayerKey = playerKey
                    topCategory = categoryLabel
                    topPriority = categoryPriority
                    contenders = { playerKey }
                elseif samePriority and rolled == topRoll and not listContains(contenders, playerKey) then
                    contenders[#contenders + 1] = playerKey
                end
            end
        end
    end

    return {
        topRoll = topRoll,
        topPlayer = topPlayer,
        topPlayerKey = topPlayerKey,
        topCategory = topCategory,
        topPriority = topPriority,
        contenders = contenders,
        rollCount = rollCount,
    }
end

local function getKeyLookup(list)
    local lookup = {}
    for i = 1, #(list or {}) do
        lookup[list[i]] = true
    end
    return lookup
end

updateSessionRollSummary = function(row)
    row = ensureSessionItemState(row)
    if not row then return nil end

    local baseSummary = summarizeSessionRolls(row.rolls, saved.categoryPriorityMode, nil)
    local shouldCloseBase = row.state == "open"
        and (time() >= (tonumber(row.expiresAt) or 0)
            or (row.eligibleCount > 0 and baseSummary.rollCount >= row.eligibleCount))

    row.rollCount = baseSummary.rollCount
    row.rolloffReady = false

    if row.state == "open" and shouldCloseBase then
        row.state = "closed"
    end

    if row.rolloffActive then
        local rolloffLookup = getKeyLookup(row.rolloffParticipants)
        local rolloffSummary = summarizeSessionRolls(row.rolloffRolls, false, rolloffLookup)

        row.rolloffCount = rolloffSummary.rollCount
        row.topRoll = rolloffSummary.topRoll
        row.topPlayer = (#rolloffSummary.contenders == 1) and rolloffSummary.topPlayer or nil
        row.topPlayerKey = (#rolloffSummary.contenders == 1) and rolloffSummary.topPlayerKey or nil
        row.topCategory = rolloffSummary.topRoll and "Roll-Off" or nil

        if #rolloffSummary.contenders > 1 then
            row.tiePlayerKeys = copyList(rolloffSummary.contenders)
        else
            row.tiePlayerKeys = copyList(row.rolloffParticipants)
        end

        if time() >= (row.rolloffExpiresAt or 0)
            or (#row.rolloffParticipants > 0 and row.rolloffCount >= #row.rolloffParticipants) then
            row.rolloffReady = true
        end

        row.state = "rolloff"
        return row
    end

    row.rolloffCount = 0
    if row.rolloffWinnerKey and row.rolloffWinnerKey ~= "" then
        local winningRoll = row.rolls and row.rolls[row.rolloffWinnerKey] or nil
        row.topPlayerKey = row.rolloffWinnerKey
        row.topPlayer = row.rolloffWinnerName or (winningRoll and winningRoll.playerName) or row.rolloffWinnerKey
        row.topRoll = row.rolloffWinnerRoll or (winningRoll and tonumber(winningRoll.rolled)) or baseSummary.topRoll
        row.topCategory = "Roll-Off"
        row.tiePlayerKeys = {}
    else
        row.topRoll = baseSummary.topRoll
        row.topCategory = baseSummary.topCategory

        if row.state == "closed" and #baseSummary.contenders > 1 then
            row.topPlayer = nil
            row.topPlayerKey = nil
            row.tiePlayerKeys = copyList(baseSummary.contenders)
        else
            row.topPlayer = (#baseSummary.contenders == 1) and baseSummary.topPlayer or nil
            row.topPlayerKey = (#baseSummary.contenders == 1) and baseSummary.topPlayerKey or nil
            row.tiePlayerKeys = {}
        end
    end

    return row
end

local function findFirstOpenSessionItem()
    ensureLootSession()
    for i = 1, #saved.lootSession do
        local row = updateSessionRollSummary(saved.lootSession[i])
        if row and row.state ~= "closed" then
            return row
        end
    end
    return nil
end

local function getSelectedSessionItem()
    local selected = selectedSessionItemId and getSessionItemById(selectedSessionItemId) or nil
    if selected then
        updateSessionRollSummary(selected)
        return selected
    end

    local firstOpen = findFirstOpenSessionItem()
    if firstOpen then
        selectedSessionItemId = firstOpen.id
        return firstOpen
    end

    return nil
end

hasActiveSessionRolls = function()
    ensureLootSession()
    for i = 1, #saved.lootSession do
        local row = updateSessionRollSummary(saved.lootSession[i])
        if row and (row.state == "open" or row.rolloffActive or #row.tiePlayerKeys > 1) then
            return true
        end
    end
    return false
end

local function hasPlayerRolledForSessionItem(itemId, playerKey)
    local row = getSessionItemById(itemId)
    if not row or type(row.rolls) ~= "table" then
        return false
    end
    return row.rolls[playerKey] ~= nil
end

local function hasPlayerRolledRollOff(itemId, playerKey)
    local row = getSessionItemById(itemId)
    if not row or type(row.rolloffRolls) ~= "table" then
        return false
    end
    return row.rolloffRolls[playerKey] ~= nil
end

local function startSessionRollOff(row, participants, expiresAt, round, suppressSync)
    row = ensureSessionItemState(row)
    if not row or #(participants or {}) < 2 then
        return false
    end

    row.rolloffRound = tonumber(round) or ((tonumber(row.rolloffRound) or 0) + 1)
    row.rolloffActive = true
    row.rolloffExpiresAt = tonumber(expiresAt) or (time() + ROLLOFF_DURATION_SECONDS)
    row.rolloffParticipants = copyList(participants)
    row.rolloffRolls = {}
    row.rolloffWinnerKey = nil
    row.rolloffWinnerName = nil
    row.rolloffWinnerRoll = nil
    row.tiePlayerKeys = copyList(participants)
    row.lastDecision = string.format("Roll-off started for %d tied players", #participants)
    row.state = "rolloff"

    updateSessionRollSummary(row)

    if not suppressSync and isRaidLeader() then
        sendComm("ROLLOFF_START", row.id, tostring(row.rolloffRound), tostring(row.rolloffExpiresAt), serializeKeyList(participants))
    end

    return true
end

local function addSessionRollOffRoll(itemId, round, playerKey, playerName, rolled, maxValue)
    local row = getSessionItemById(itemId)
    if not row then return false end

    updateSessionRollSummary(row)
    if not row.rolloffActive then
        return false
    end
    if tonumber(round) ~= tonumber(row.rolloffRound) then
        return false
    end
    if not listContains(row.rolloffParticipants, playerKey) then
        return false
    end
    if row.rolloffRolls[playerKey] then
        return false
    end

    row.rolloffRolls[playerKey] = {
        playerName = playerName or playerKey,
        rolled = tonumber(rolled) or 0,
        max = tonumber(maxValue) or 100,
        status = "active",
        ts = time(),
    }

    updateSessionRollSummary(row)
    refreshLeaderboardPopup()
    return true, row
end

local function advanceSessionRowState(row)
    row = updateSessionRollSummary(row)
    if not row then
        return false
    end

    if isRaidLeader() and row.rolloffActive and row.rolloffReady then
        local participants = copyList(row.tiePlayerKeys)
        if #participants == 1 then
            local winnerKey = participants[1]
            local winnerRoll = row.rolloffRolls[winnerKey]
            row.rolloffActive = false
            row.rolloffExpiresAt = 0
            row.rolloffWinnerKey = winnerKey
            row.rolloffWinnerName = (winnerRoll and winnerRoll.playerName) or winnerKey
            row.rolloffWinnerRoll = (winnerRoll and tonumber(winnerRoll.rolled)) or row.topRoll or 0
            row.rolloffParticipants = {}
            row.tiePlayerKeys = {}
            row.lastDecision = string.format("Roll-off won by %s", row.rolloffWinnerName)
            row.state = "closed"
            updateSessionRollSummary(row)
            addRecentEvent("loot", string.format("%s roll-off won by %s", row.itemName or "Item", row.rolloffWinnerName), nil)
            sendComm("ROLLOFF_RESOLVE", row.id, tostring(row.rolloffRound or 0), winnerKey, row.rolloffWinnerName, tostring(row.rolloffWinnerRoll or 0))
            return true
        elseif #participants > 1 then
            local nextRound = (tonumber(row.rolloffRound) or 0) + 1
            addRecentEvent("loot", string.format("%s roll-off tied again; starting round %d", row.itemName or "Item", nextRound), nil)
            startSessionRollOff(row, participants, time() + ROLLOFF_DURATION_SECONDS, nextRound, false)
            return true
        end
    elseif isRaidLeader() and row.state == "closed" and row.winner == "Pending" and not row.rolloffWinnerKey and #row.tiePlayerKeys > 1 then
        startSessionRollOff(row, row.tiePlayerKeys, time() + ROLLOFF_DURATION_SECONDS, nil, false)
        addRecentEvent("loot", string.format("%s tied for top roll; roll-off started", row.itemName or "Item"), nil)
        return true
    end

    return false
end

local function addSessionItem(itemName, itemLink, source, itemId, createdAt, expiresAt, eligibleCount)
    ensureLootSession()

    local row = {
        id = tostring(itemId or (time() .. "-" .. tostring(math.random(1000, 9999)))),
        ts = tonumber(createdAt) or time(),
        itemName = itemName or "Unknown Item",
        itemLink = itemLink,
        winner = "Pending",
        source = source or "loot",
        state = "open",
        expiresAt = tonumber(expiresAt) or ((tonumber(createdAt) or time()) + 120),
        eligibleCount = tonumber(eligibleCount) or getEligibleSessionRollerCount(),
        rolls = {},
    }
    updateSessionRollSummary(row)

    table.insert(saved.lootSession, 1, row)
    selectedSessionItemId = selectedSessionItemId or row.id

    while #saved.lootSession > 120 do
        table.remove(saved.lootSession)
    end

    addRecentEvent("loot", string.format("Session item added: %s", itemName or "Unknown Item"), nil)
    return row
end

local function addSessionRoll(itemId, playerKey, playerName, category, label, rolled, maxValue, multiplier)
    local row = getSessionItemById(itemId)
    if not row then return false end

    updateSessionRollSummary(row)
    if row.state ~= "open" then
        return false
    end

    if row.rolls[playerKey] then
        return false
    end

    row.rolls[playerKey] = {
        playerName = playerName or playerKey,
        category = category,
        label = label,
        rolled = tonumber(rolled) or 0,
        max = tonumber(maxValue) or 0,
        multiplier = tonumber(multiplier) or 1.0,
        status = "active",
        ts = time(),
    }

    updateSessionRollSummary(row)
    advanceSessionRowState(row)
    refreshLeaderboardPopup()
    return true, row
end

local function awardSessionItem(itemId, actorName, applyPenalty)
    local row = getSessionItemById(itemId)
    local topPlayerKey, topRoll = getSessionTopRollEntry(row)
    if not row or row.rolloffActive or #row.tiePlayerKeys > 1 or not topPlayerKey or not topRoll then
        return false
    end

    row.winner = topRoll.playerName or topPlayerKey
    row.state = "awarded"
    row.lastDecision = string.format("Awarded to %s", row.winner)
    topRoll.status = "awarded"

    local multiplierAfterAward
    local winsAfterAward
    if applyPenalty then
        applyWinPenalty(topPlayerKey, actorName or GetUnitName("player", true) or "Raid Leader")
        multiplierAfterAward = getPlayerMultiplier(topPlayerKey)
        winsAfterAward = tonumber((saved.wins or {})[topPlayerKey]) or 0
    end

    updateSessionRollSummary(row)
    refreshLeaderboardPopup()

    addRecentEvent("loot", string.format("%s awarded to %s", row.itemName or "Item", row.winner), nil)
    applyAutoCloseIfComplete()
    return true, row, topPlayerKey, row.winner, multiplierAfterAward, winsAfterAward
end

local function denySessionItemTopRoll(itemId)
    local row = getSessionItemById(itemId)
    local topPlayerKey, topRoll = getSessionTopRollEntry(row)
    if not row or row.rolloffActive or #row.tiePlayerKeys > 1 or not topPlayerKey or not topRoll then
        return false
    end

    local deniedName = topRoll.playerName or topPlayerKey
    topRoll.status = "denied"
    row.lastDecision = string.format("Denied for %s", deniedName)
    if row.rolloffWinnerKey == topPlayerKey then
        row.rolloffWinnerKey = nil
        row.rolloffWinnerName = nil
        row.rolloffWinnerRoll = nil
    end

    if row.state == "awarded" then
        row.state = "open"
        row.winner = "Pending"
    end

    updateSessionRollSummary(row)
    advanceSessionRowState(row)
    refreshLeaderboardPopup()
    addRecentEvent("loot", string.format("%s denied for %s", row.itemName or "Item", deniedName), nil)
    applyAutoCloseIfComplete()
    return true, row, topPlayerKey, deniedName
end

local function clearLootSession()
    if hasActiveSessionRolls() then
        return false, "Cannot clear the loot session while active rolls remain."
    end

    saved.lootSession = {}
    selectedSessionItemId = nil
    setBossLootSession(false)
    refreshLootSessionWindow()
    addRecentEvent("loot", "Loot session cleared", nil)
    applyAutoCloseIfComplete()
    return true
end

local function updateSessionWinner(itemLink, winnerName)
    if not itemLink or itemLink == "" then return end
    ensureLootSession()

    for i = 1, #saved.lootSession do
        local row = updateSessionRollSummary(saved.lootSession[i])
        if row and row.itemLink == itemLink and (row.winner == nil or row.winner == "Pending") then
            row.winner = winnerName or "Unknown"
            row.state = "closed"
            addRecentEvent("loot", string.format("%s won %s", row.winner, row.itemName or row.itemLink), nil)
            return row
        end
    end
end

local function findPendingSessionItemByLoot(itemLink, itemName)
    ensureLootSession()
    for i = 1, #saved.lootSession do
        local row = updateSessionRollSummary(saved.lootSession[i])
        if row and (row.winner == nil or row.winner == "Pending") then
            if itemLink and itemLink ~= "" and row.itemLink == itemLink then
                return row
            end
            if (not row.itemLink or row.itemLink == "") and itemName and itemName ~= "" and row.itemName == itemName then
                return row
            end
        end
    end
    return nil
end

local function normalizeTradeTarget(rawName)
    local cleaned = tostring(rawName or "")
    cleaned = string.gsub(cleaned, "%(%*%)$", "")
    if cleaned == "" then
        return nil
    end
    return normalizeFullNameToKey(cleaned), cleaned
end

local function getContainerItemLinkSafe(bag, slot)
    if C_Container and C_Container.GetContainerItemLink then
        return C_Container.GetContainerItemLink(bag, slot)
    end
    return GetContainerItemLink and GetContainerItemLink(bag, slot) or nil
end

local function pickupContainerItemSafe(bag, slot)
    if C_Container and C_Container.PickupContainerItem then
        C_Container.PickupContainerItem(bag, slot)
    end
end

local function findBagSlotForItemLink(itemLink, usedSlots)
    if not itemLink or itemLink == "" then
        return nil, nil
    end
    for bag = 0, (NUM_BAG_SLOTS or 4) do
        local numSlots = C_Container and C_Container.GetContainerNumSlots and C_Container.GetContainerNumSlots(bag)
            or (GetContainerNumSlots and GetContainerNumSlots(bag))
            or 0
        for slot = 1, numSlots do
            local slotId = bag .. ":" .. slot
            if not usedSlots[slotId] then
                local link = getContainerItemLinkSafe(bag, slot)
                if link and link == itemLink then
                    return bag, slot
                end
            end
        end
    end
    return nil, nil
end

tryAutoTradeAwardedItems = function()
    if not saved or not saved.autoTrade then
        return
    end
    if InCombatLockdown and InCombatLockdown() then
        return
    end
    if not saved.raidActive or not isInRaid() then
        return
    end

    local targetText = _G and _G.TradeFrameRecipientNameText and _G.TradeFrameRecipientNameText:GetText() or nil
    local targetKey = normalizeTradeTarget(targetText)
    if not targetKey then
        return
    end

    local usedSlots = {}
    local added = 0
    for i = 1, #(saved.lootSession or {}) do
        local row = saved.lootSession[i]
        if row
            and row.state == "awarded"
            and row.itemLink
            and row.itemLink ~= ""
            and (not row.autoTraded)
            and normalizeFullNameToKey(row.winner) == targetKey then
            local bag, slot = findBagSlotForItemLink(row.itemLink, usedSlots)
            if bag and slot then
                ClearCursor()
                pickupContainerItemSafe(bag, slot)
                ClickTradeButton(added + 1)
                usedSlots[bag .. ":" .. slot] = true
                row.autoTraded = true
                row.autoTradeTarget = targetText
                row.autoTradeAt = time()
                added = added + 1
            end
            if added >= ((MAX_TRADE_ITEMS or 7) - 1) then
                break
            end
        end
    end

    if added > 0 then
        addRecentEvent("loot", string.format("Auto-trade queued %d item(s) for %s", added, targetText or "trade target"), nil)
        refreshLootSessionWindow()
    end
end

local function buildSessionStatusText(row)
    row = updateSessionRollSummary(row)
    if not row then return "" end

    local progress = string.format("%d/%d", tonumber(row.rollCount) or 0, tonumber(row.eligibleCount) or 0)
    local remaining = math.max(0, (tonumber(row.expiresAt) or time()) - time())
    local rolloffRemaining = math.max(0, (tonumber(row.rolloffExpiresAt) or time()) - time())

    if row.winner and row.winner ~= "Pending" then
        return string.format("Awarded: %s", row.winner)
    end

    if row.rolloffActive then
        local tieCount = #(row.rolloffParticipants or {})
        local rolloffProgress = string.format("%d/%d", tonumber(row.rolloffCount) or 0, tieCount)
        if row.topRoll and row.topPlayer then
            return string.format("Roll-off R%d: %d %s  %s  %ds", tonumber(row.rolloffRound) or 1, row.topRoll, row.topPlayer, rolloffProgress, rolloffRemaining)
        end
        return string.format("Roll-off R%d: waiting  %s  %ds", tonumber(row.rolloffRound) or 1, rolloffProgress, rolloffRemaining)
    end

    if #row.tiePlayerKeys > 1 then
        return string.format("Closed: Tie detected between %d players  %s", #row.tiePlayerKeys, progress)
    end

    if row.lastDecision and row.lastDecision ~= "" and row.topRoll and row.topPlayer then
        return string.format("%s | Top: %d %s [%s]", row.lastDecision, row.topRoll, row.topPlayer, row.topCategory or "Roll")
    end

    if row.topRoll and row.topPlayer then
        if row.state == "closed" then
            return string.format("Closed: %d %s [%s]  %s", row.topRoll, row.topPlayer, row.topCategory or "Roll", progress)
        end
        return string.format("Top: %d %s [%s]  %s  %ds", row.topRoll, row.topPlayer, row.topCategory or "Roll", progress, remaining)
    end

    if row.state == "closed" then
        return string.format("Closed: No rolls  %s", progress)
    end

    return string.format("Open: %s  %ds", progress, remaining)
end

local function tickLootSessionState()
    ensureLootSession()
    if (not lootSessionFrame or not lootSessionFrame:IsShown()) and #(saved.lootSession or {}) == 0 then
        return
    end

    local changed = false

    for i = 1, #saved.lootSession do
        local row = saved.lootSession[i]
        local previousState = row and row.state or nil
        updateSessionRollSummary(row)
        if advanceSessionRowState(row) then
            changed = true
        end
        if row and previousState ~= row.state then
            changed = true
        end
    end

    if selectedSessionItemId and not getSessionItemById(selectedSessionItemId) then
        selectedSessionItemId = nil
        changed = true
    end

    if changed or (lootSessionFrame and lootSessionFrame:IsShown()) then
        refreshLootSessionWindow()
    end
    applyAutoCloseIfComplete()
end

refreshLootSessionWindow = function()
    if not lootSessionFrame or not lootSessionFrame.rows then return end
    ensureLootSession()

    for i = 1, #lootSessionFrame.rows do
        local row = saved.lootSession[i]
        local button = lootSessionFrame.rows[i]
        if row then
            updateSessionRollSummary(row)
            button.rowId = row.id
            button:Show()
            button.left:SetText(row.itemLink or row.itemName or "Unknown Item")
            button.right:SetText(buildSessionStatusText(row))

            local canDecide = isRaidLeader() and not row.rolloffActive and #row.tiePlayerKeys == 0 and row.winner == "Pending" and (row.rolloffWinnerKey ~= nil or row.topPlayerKey ~= nil)
            button.awardButton:SetShown(canDecide)
            button.denyButton:SetShown(canDecide)
            button.awardButton:SetEnabled(canDecide)
            button.denyButton:SetEnabled(canDecide)

            if row.id == selectedSessionItemId then
                button.bg:SetColorTexture(0.12, 0.95, 0.22, 0.42)
                button:SetBackdropBorderColor(0.15, 1.00, 0.28, 0.95)
            elseif row.state == "closed" then
                button.bg:SetColorTexture(1, 1, 1, 0.05)
                button:SetBackdropBorderColor(0.45, 0.45, 0.45, 0.7)
            else
                button.bg:SetColorTexture(1, 1, 1, 0.1)
                button:SetBackdropBorderColor(0.6, 0.6, 0.6, 0.8)
            end
        else
            button.rowId = nil
            button.left:SetText("")
            button.right:SetText("")
            button.bg:SetColorTexture(1, 1, 1, 0)
            button:SetBackdropBorderColor(0.6, 0.6, 0.6, 0.8)
            button.awardButton:Hide()
            button.denyButton:Hide()
            button:Hide()
        end
    end

    local selected = getSelectedSessionItem()
    if selected then
        lootSessionFrame.selection:SetText(string.format("Selected: %s [%s]", selected.itemLink or selected.itemName or "Unknown Item", getSessionItemClassification(selected)))
        lootSessionFrame.selectionStatus:SetText(buildSessionStatusText(selected))
    else
        lootSessionFrame.selection:SetText("Selected: none")
        lootSessionFrame.selectionStatus:SetText("Select a loot item, then use the DetentionRolls buttons to roll for it.")
    end

    if lootSessionFrame.clearButton then
        local canClear = not hasActiveSessionRolls() and #saved.lootSession > 0
        lootSessionFrame.clearButton:SetEnabled(canClear)
    end
end

refreshLeaderboardPopup = function()
    if not leaderboardFrame or not leaderboardFrame.lines then return end

    local rows = buildLeaderboardRows()
    local visibleCount = math.max(10, math.min(30, #rows))

    leaderboardFrame.title:SetText(string.format("DetentionRolls (%s)", saved.raidActive and "Active" or "Inactive"))
    leaderboardFrame:SetHeight(40 + (visibleCount * 18))

    for i = 1, #leaderboardFrame.lines do
        local row = rows[i]
        if row then
            leaderboardFrame.lines[i]:SetText(string.format("%d) %s  %.2f  (%d)", i, row.display, row.multiplier, row.wins))
            leaderboardFrame.lines[i]:Show()
        else
            leaderboardFrame.lines[i]:SetText("")
            leaderboardFrame.lines[i]:Hide()
        end
    end
end

local function createCompactLeaderboardPopup()
    if leaderboardFrame then return leaderboardFrame end

    local panel = CreateFrame("Frame", "DR_LeaderboardPopup", UIParent, "BackdropTemplate")
    panel:SetSize(265, 220)
    panel:SetPoint("TOP", UIParent, "TOP", 0, -160)
    panel:SetFrameStrata("HIGH")
    panel:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = true,
        tileSize = 16,
        edgeSize = 12,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    panel:SetMovable(true)
    panel:EnableMouse(true)
    panel:RegisterForDrag("LeftButton")
    panel:SetScript("OnDragStart", startWindowDrag)
    panel:SetScript("OnDragStop", stopWindowDrag)
    applyWindowLockState(panel)

    local title = panel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    title:SetPoint("TOP", 0, -10)
    title:SetText("DetentionRolls")
    panel.title = title

    local closeBtn = CreateFrame("Button", nil, panel, "UIPanelCloseButton")
    closeBtn:SetPoint("TOPRIGHT", -4, -4)

    local lines = {}
    for i = 1, 30 do
        local f = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        f:SetPoint("TOPLEFT", 10, -24 - ((i - 1) * 18))
        f:SetWidth(245)
        f:SetJustifyH("LEFT")
        lines[i] = f
    end
    panel.lines = lines

    panel:SetShown(canAutoOpenLootWindows() and saved.showCompactLeaderboard)

    leaderboardFrame = panel
    UiAutomation:RegisterWindow(panel)
    refreshLeaderboardPopup()
    return panel
end

createLootSessionWindow = function()
    if lootSessionFrame then return lootSessionFrame end

    local panel = CreateFrame("Frame", "DR_LootSessionWindow", UIParent, "BackdropTemplate")
    panel:SetSize(560, 432)
    panel:SetPoint("CENTER", UIParent, "CENTER", 300, 0)
    panel:SetFrameStrata("DIALOG")
    panel:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
        edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
        tile = true,
        tileSize = 32,
        edgeSize = 32,
        insets = { left = 11, right = 12, top = 12, bottom = 11 },
    })
    panel:SetMovable(true)
    panel:EnableMouse(true)
    panel:RegisterForDrag("LeftButton")
    panel:SetScript("OnDragStart", startWindowDrag)
    panel:SetScript("OnDragStop", stopWindowDrag)
    applyWindowLockState(panel)

    local title = panel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    title:SetPoint("TOP", 0, -14)
    title:SetText("DetentionRolls Loot Session")

    local rollBar = CreateFrame("Frame", nil, panel)
    rollBar:SetPoint("TOPLEFT", 14, -34)
    rollBar:SetPoint("TOPRIGHT", -14, -34)
    rollBar:SetHeight(28)

    local rollButtons = {}
    local rollButtonWidth = 78
    local rollButtonSpacing = 6
    local totalRollWidth = (#rolls * rollButtonWidth) + ((#rolls - 1) * rollButtonSpacing)
    local rollStart = -(totalRollWidth / 2) + (rollButtonWidth / 2)

    for i, data in ipairs(rolls) do
        local btn = CreateFrame("Button", nil, rollBar, "BackdropTemplate")
        btn:SetSize(rollButtonWidth, 24)
        btn:SetPoint("TOP", rollBar, "TOP", rollStart + ((i - 1) * (rollButtonWidth + rollButtonSpacing)), 0)
        btn:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            tile = false,
            edgeSize = 8,
            insets = { left = 2, right = 2, top = 2, bottom = 2 },
        })
        btn:SetBackdropColor(0.08, 0.08, 0.08, 0.95)
        btn:SetBackdropBorderColor(0.65, 0.65, 0.65, 0.9)

        btn.text = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        btn.text:SetPoint("CENTER")
        btn.text:SetText(data.name)

        if data.isGold then
            local bFont, bSize = btn.text:GetFont()
            btn.text:SetFont(bFont, bSize * 1.1, "OUTLINE")
            btn.text:SetTextColor(1, 0.84, 0)
        end

        btn:SetScript("OnClick", function()
            doRoll(data)
        end)

        btn:SetScript("OnEnter", function(self)
            local playerKey = getSelfKey()
            local effMax = computeEffectiveMax(data.max, data.category, playerKey)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(data.name, 1, 1, 1)
            GameTooltip:AddLine(data.tooltipTitle or "", 0.8, 0.8, 0.8, true)
            GameTooltip:AddLine("Roll: 0-" .. data.max, 0, 1, 0)
            GameTooltip:AddLine("Effective: 0-" .. effMax, 0.3, 0.9, 1)
            GameTooltip:Show()
        end)

        btn:SetScript("OnLeave", function()
            GameTooltip:Hide()
        end)

        rollButtons[i] = btn
    end

    local hint = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    hint:SetPoint("TOPLEFT", 16, -68)
    hint:SetPoint("TOPRIGHT", -16, -68)
    hint:SetJustifyH("LEFT")
    hint:SetText("Select an item below, then use the DetentionRolls roll buttons for that item.")

    local closeBtn = CreateFrame("Button", nil, panel, "UIPanelCloseButton")
    closeBtn:SetPoint("TOPRIGHT", -4, -4)

    local clearButton = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    clearButton:SetSize(92, 20)
    clearButton:SetPoint("TOPRIGHT", -30, -64)
    clearButton:SetText("Clear Session")
    clearButton:SetScript("OnClick", function()
        local ok, reason = clearLootSession()
        if ok then
            if isRaidLeader() then
                sendComm("SESSION_CLEAR")
            end
        else
            print("|cffffaa00DetentionRolls:|r " .. (reason or "Could not clear the loot session."))
        end
    end)

    local rows = {}
    for i = 1, 14 do
        local rowButton = CreateFrame("Button", nil, panel, "BackdropTemplate")
        rowButton:SetPoint("TOPLEFT", 14, -92 - ((i - 1) * 21))
        rowButton:SetSize(530, 19)
        rowButton:SetBackdrop({
            bgFile = "Interface\\Buttons\\WHITE8x8",
            edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
            tile = false,
            edgeSize = 8,
            insets = { left = 2, right = 2, top = 2, bottom = 2 },
        })
        rowButton:SetBackdropColor(0, 0, 0, 0.15)

        rowButton.bg = rowButton:CreateTexture(nil, "BACKGROUND")
        rowButton.bg:SetAllPoints()
        rowButton.bg:SetColorTexture(1, 1, 1, 0.1)

        rowButton.left = rowButton:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        rowButton.left:SetPoint("LEFT", 6, 0)
        rowButton.left:SetWidth(210)
        rowButton.left:SetJustifyH("LEFT")

        rowButton.right = rowButton:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        rowButton.right:SetPoint("RIGHT", -90, 0)
        rowButton.right:SetWidth(220)
        rowButton.right:SetJustifyH("RIGHT")

        local awardButton = CreateFrame("Button", nil, rowButton, "UIPanelButtonTemplate")
        awardButton:SetSize(38, 16)
        awardButton:SetPoint("RIGHT", -44, 0)
        awardButton:SetText("Yes")
        awardButton:SetScript("OnClick", function(self)
            local parent = self:GetParent()
            if not parent.rowId then return end
            local ok, row, playerKey, winnerName, newMultiplier, wins = awardSessionItem(parent.rowId, GetUnitName("player", true), true)
            if ok and isRaidLeader() then
                sendComm("SESSION_DECIDE", parent.rowId, "award", playerKey or "", winnerName or "")
                if playerKey and newMultiplier then
                    sendComm("WIN_APPLY", playerKey, tostring(newMultiplier), tostring(wins or 0), GetUnitName("player", true) or "leader")
                end
            end
            refreshLootSessionWindow()
        end)

        local denyButton = CreateFrame("Button", nil, rowButton, "UIPanelButtonTemplate")
        denyButton:SetSize(38, 16)
        denyButton:SetPoint("RIGHT", -4, 0)
        denyButton:SetText("No")
        denyButton:SetScript("OnClick", function(self)
            local parent = self:GetParent()
            if not parent.rowId then return end
            local ok, row, playerKey, deniedName = denySessionItemTopRoll(parent.rowId)
            if ok and isRaidLeader() then
                sendComm("SESSION_DECIDE", parent.rowId, "deny", playerKey or "", deniedName or "")
            end
            refreshLootSessionWindow()
        end)

        rowButton.awardButton = awardButton
        rowButton.denyButton = denyButton

        rowButton:SetScript("OnClick", function(self)
            if self.rowId then
                selectedSessionItemId = self.rowId
                refreshLootSessionWindow()
            end
        end)

        rowButton:SetScript("OnEnter", function(self)
            if not self.rowId then
                return
            end

            local row = getSessionItemById(self.rowId)
            if not row then
                return
            end

            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            if row.itemLink and row.itemLink ~= "" then
                GameTooltip:SetHyperlink(row.itemLink)
                GameTooltip:AddLine(" ")
                GameTooltip:AddLine(buildSessionStatusText(row), 0.8, 0.95, 0.8, true)
            else
                GameTooltip:SetText(row.itemName or "Unknown Item", 1, 1, 1)
                GameTooltip:AddLine(getSessionItemClassification(row), 0.7, 0.82, 1.0, true)
                GameTooltip:AddLine(buildSessionStatusText(row), 0.8, 0.95, 0.8, true)
            end
            GameTooltip:Show()
        end)

        rowButton:SetScript("OnLeave", function()
            GameTooltip:Hide()
        end)

        rows[i] = rowButton
    end

    local selection = panel:CreateFontString(nil, "OVERLAY", "GameFontNormalLeft")
    selection:SetPoint("BOTTOMLEFT", 16, 40)
    selection:SetPoint("BOTTOMRIGHT", -16, 40)
    selection:SetJustifyH("LEFT")
    selection:SetText("Selected: none")

    local selectionStatus = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    selectionStatus:SetPoint("BOTTOMLEFT", 16, 18)
    selectionStatus:SetPoint("BOTTOMRIGHT", -16, 18)
    selectionStatus:SetJustifyH("LEFT")
    selectionStatus:SetText("Select a loot item, then use the DetentionRolls buttons to roll for it.")

    panel.rows = rows
    panel.rollButtons = rollButtons
    panel.selection = selection
    panel.selectionStatus = selectionStatus
    panel.clearButton = clearButton

    panel:Hide()

    if not lootSessionTicker then
        lootSessionTicker = C_Timer.NewTicker(1, tickLootSessionState)
    end

    lootSessionFrame = panel
    UiAutomation:RegisterWindow(panel)
    refreshLootSessionWindow()
    return panel
end

local function addFakeLootToSession()
    fakeLootSerial = fakeLootSerial + 1
    local itemName = string.format("[Fake Loot %02d]", fakeLootSerial)
    local eligibleCount = getEligibleSessionRollerCount()
    local row = addSessionItem(itemName, nil, "test", nil, time(), time() + 120, eligibleCount)

    if isInRaid() and isRaidLeader() then
        sendComm("SESSION_ADD", row.id, itemName, "", tostring(row.ts), tostring(row.expiresAt), tostring(row.eligibleCount), row.source or "test")
    end

    showLootUiWindows()
    refreshLootSessionWindow()
end

local function showRaidActivationPrompt()
    if not isRaidLeader() or not isInRaid() then return end
    if saved.raidActivationPrompted then return end
    if pendingActivationPopup then return end

    pendingActivationPopup = true
    saved.raidActivationPrompted = true

    StaticPopupDialogs["DR_ACTIVATE_RAID"] = {
        text = "Activate DetentionRolls for this raid?",
        button1 = "Activate",
        button2 = "Not now",
        OnAccept = function()
            saved.raidActive = true
            pendingActivationPopup = false
            sendComm("RAID_ACTIVATE", "1", GetUnitName("player", true) or "leader")
            sendSnapshot()
            addRecentEvent("admin", "Raid Leader activated DetentionRolls for this raid", nil)
            if leaderboardFrame then
                leaderboardFrame:SetShown(saved.showCompactLeaderboard)
            end
            print("|cff00ff00DetentionRolls:|r Raid mode activated.")
        end,
        OnCancel = function()
            pendingActivationPopup = false
            saved.raidActive = false
            sendComm("RAID_ACTIVATE", "0", GetUnitName("player", true) or "leader")
            if leaderboardFrame then
                leaderboardFrame:SetShown(false)
            end
            print("|cffffff00DetentionRolls:|r Raid mode not activated.")
        end,
        timeout = 0,
        whileDead = true,
        hideOnEscape = true,
        preferredIndex = 3,
    }

    StaticPopup_Show("DR_ACTIVATE_RAID")
end

local function applyAutoLootPolicy(rollID, canNeed, canGreed)
    if not saved.raidActive then return end
    if not isInRaid() then return end

    if isRaidLeader() then
        if saved.leaderAutoLootMode == "off" then
            return
        end

        if canNeed and (saved.leaderAutoLootMode == "need" or saved.leaderAutoLootMode == "need_or_greed") then
            RollOnLoot(rollID, 1)
            return
        end

        if canGreed and (saved.leaderAutoLootMode == "greed" or saved.leaderAutoLootMode == "need_or_greed") then
            RollOnLoot(rollID, 2)
            return
        end

        RollOnLoot(rollID, 0)
        return
    end

    if saved.autoPassNonLeader then
        RollOnLoot(rollID, 0)
    end
end

local function onStartLootRoll(rollID, rollTime)
    local itemLink = GetLootRollItemLink(rollID)
    local texture, itemName, count, quality, bop, canNeed, canGreed = GetLootRollItemInfo(rollID)

    local displayName = itemName or itemLink or ("Loot Roll #" .. tostring(rollID))
    if isRaidLeader() and saved.raidActive and isInRaid() then
        if not isBossLootSessionOpen() then
            SessionGate:StartBossSession(nil, 240)
        end
        syncCurrentRaidMembersToSaved()
        local eligibleCount = getEligibleSessionRollerCount()
        local row = addSessionItem(displayName, itemLink, "loot", nil, time(), time() + 120, eligibleCount)
        sendComm("SESSION_ADD", row.id, displayName, itemLink or "", tostring(row.ts), tostring(row.expiresAt), tostring(row.eligibleCount), row.source or "loot")
        showLootUiWindows()
    end

    applyAutoLootPolicy(rollID, canNeed, canGreed)
    refreshLootSessionWindow()
    applyAutoCloseIfComplete()
end

local function addManualSessionItem(raw)
    local trimmed = string.gsub(raw or "", "^%s+", "")
    trimmed = string.gsub(trimmed, "%s+$", "")
    if trimmed == "" then
        return false, "Usage: /dr add <item link or name>"
    end

    local itemLink = string.match(trimmed, "(|c%x+|Hitem:[^|]+|h%[[^%]]+%]|h|r)") or string.match(trimmed, "(|Hitem:[^|]+|h%[[^%]]+%]|h)")
    local itemName = trimmed
    if itemLink then
        itemName = string.match(itemLink, "|h%[([^%]]+)%]|h") or itemLink
    end

    local eligibleCount = getEligibleSessionRollerCount()
    local row = addSessionItem(itemName, itemLink, "manual", nil, time(), time() + 120, eligibleCount)
    local panel = createLootSessionWindow()
    if not panel:IsShown() then
        panel:Show()
    end
    refreshLootSessionWindow()

    if isRaidLeader() then
        sendComm("SESSION_ADD", row.id, itemName, itemLink or "", tostring(row.ts), tostring(row.expiresAt), tostring(row.eligibleCount), row.source or "manual")
    end

    return true, string.format("Added to session: %s", itemName)
end

doRoll = function(rollData)
    local playerKey, fullName = getSelfKey()
    local chatType = chooseChatType()
    local sessionRow = getSelectedSessionItem()

    if sessionRow and sessionRow.rolloffActive then
        updateSessionRollSummary(sessionRow)
        if not listContains(sessionRow.rolloffParticipants, playerKey) then
            print("|cffffaa00DetentionRolls:|r Only tied players can participate in this roll-off.")
            return
        end
        if hasPlayerRolledRollOff(sessionRow.id, playerKey) then
            print("|cffffaa00DetentionRolls:|r You have already rolled in the current roll-off.")
            return
        end

        local rolloffLabel = string.format("Roll-Off R%d", tonumber(sessionRow.rolloffRound) or 1)
        if C_ChatInfo and C_ChatInfo.SendChatMessage then
            C_ChatInfo.SendChatMessage(string.format("My Roll-Off: %s [0-100] for %s", rolloffLabel, sessionRow.itemName or "selected loot"), chatType)
        end

        pendingLocalRoll = {
            phase = "rolloff",
            fullName = fullName,
            key = playerKey,
            label = rolloffLabel,
            max = 100,
            multiplier = 1.0,
            itemId = sessionRow.id,
            itemName = sessionRow.itemName,
            round = tonumber(sessionRow.rolloffRound) or 0,
            ts = GetTime(),
        }

        C_Timer.After(0.15, function()
            RandomRoll(0, 100)
        end)
        return
    end

    local effectiveMax, multiplier = computeEffectiveMax(rollData.max, rollData.category, playerKey)

    if sessionRow then
        updateSessionRollSummary(sessionRow)
        if sessionRow.state == "closed" then
            print("|cffffaa00DetentionRolls:|r The selected loot item is closed for rolling.")
            refreshLootSessionWindow()
            return
        end
        if hasPlayerRolledForSessionItem(sessionRow.id, playerKey) then
            print("|cffffaa00DetentionRolls:|r You have already rolled for the selected loot item.")
            return
        end
    end

    local announceText
    if sessionRow then
        announceText = string.format("My Roll: %s [0-%d] for %s", rollData.msg, effectiveMax, sessionRow.itemName or "selected loot")
    else
        announceText = string.format("My Roll: %s [0-%d]", rollData.msg, effectiveMax)
    end
    if C_ChatInfo and C_ChatInfo.SendChatMessage then
        C_ChatInfo.SendChatMessage(announceText, chatType)
    end

    pendingLocalRoll = {
        fullName = fullName,
        key = playerKey,
        category = rollData.category,
        label = rollData.name,
        max = effectiveMax,
        multiplier = multiplier,
        itemId = sessionRow and sessionRow.id or nil,
        itemName = sessionRow and sessionRow.itemName or nil,
        ts = GetTime(),
    }

    C_Timer.After(0.15, function()
        RandomRoll(0, effectiveMax)
    end)
end

local function saveFramePosition()
    local point, _, relativePoint, xOfs, yOfs = frame:GetPoint()
    saved.framePos = { point = point, relativePoint = relativePoint, x = xOfs, y = yOfs }
end

local function restoreFramePosition()
    if saved.framePos then
        frame:ClearAllPoints()
        frame:SetPoint(saved.framePos.point or "CENTER", UIParent, saved.framePos.relativePoint or "CENTER", saved.framePos.x or 0, saved.framePos.y or 0)
    else
        frame:SetPoint("CENTER")
    end
end

local function updateFrameVisuals()
    if not frame then return end

    frame:SetScale(saved.scale)
    frame:SetFrameStrata(saved.strata)

    if saved.hoverMode then
        if not MouseIsOver(frame) then
            frame:SetAlpha(saved.frameAlpha or 0)
        else
            frame:SetAlpha(1)
        end
    else
        frame:SetAlpha(1)
    end

    applyWindowLockState(frame)
    applyWindowLockState(contextMenu)
    applyWindowLockState(leaderboardFrame)
    applyWindowLockState(lootSessionFrame)
    if toggleButton then
        toggleButton:SetMovable(canMoveWindows())
    end

    local ok = pcall(function()
        if saved.locked then
            frame.bg:SetAtlas("toast-background", false)
        else
            frame.bg:SetAtlas("gearupdate-frame-active", false)
        end
    end)

    if not ok then
        frame.bg:SetColorTexture(0.1, 0.1, 0.1, 0.9)
    end

    local btnW, btnH, pad = 62, 22, 5
    local num = #rolls
    local titleWidth = math.ceil(frame.title:GetStringWidth() or 0)
    local minFrameWidth = titleWidth + 24

    local totalW = (num * btnW) + ((num - 1) * pad)
    local frameWidth = math.max(totalW + 30, minFrameWidth)
    frame:SetSize(frameWidth, 64)
    local startX = -(totalW / 2) + (btnW / 2)
    for i, btn in ipairs(frame.buttons) do
        btn:ClearAllPoints()
        btn:SetPoint("TOP", frame, "TOP", startX + (i - 1) * (btnW + pad), -32)
    end
end

local function onMinimapUpdate()
    local xpos, ypos = GetCursorPosition()
    local xmin, ymin = Minimap:GetLeft(), Minimap:GetBottom()
    xpos = xmin - xpos / UIParent:GetScale() + 70
    ypos = ypos / UIParent:GetScale() - ymin - 70
    saved.minimapPos = math.deg(math.atan2(ypos, xpos))

    local radius = 80
    local cx = radius * math.cos(math.rad(saved.minimapPos))
    local cy = radius * math.sin(math.rad(saved.minimapPos))
    toggleButton:ClearAllPoints()
    toggleButton:SetPoint("CENTER", Minimap, "CENTER", -cx, cy)
end

local function updateToggleButtonPosition()
    if not toggleButton then return end

    toggleButton:ClearAllPoints()
    if saved.pinnedToMinimap then
        local radius = 80
        local cx = radius * math.cos(math.rad(saved.minimapPos or 45))
        local cy = radius * math.sin(math.rad(saved.minimapPos or 45))
        toggleButton:SetPoint("CENTER", Minimap, "CENTER", -cx, cy)
    else
        if saved.toggleButtonPos then
            toggleButton:SetPoint("CENTER", UIParent, "CENTER", saved.toggleButtonPos.x, saved.toggleButtonPos.y)
        else
            toggleButton:SetPoint("CENTER", UIParent, "CENTER", 0, -100)
        end
    end
    toggleButton:Show()
end

local function updateToggleButtonVisuals()
    if not toggleButton then return end
    toggleButton:SetFrameStrata(saved.toggleStrata or "HIGH")
    if MouseIsOver(toggleButton) then
        toggleButton:SetAlpha(1)
    else
        toggleButton:SetAlpha(saved.buttonAlpha or 1)
    end
end

local function parseRosterTextRow(line)
    if type(line) ~= "string" then
        return nil
    end

    local trimmed = string.gsub(line, "^%s+", "")
    trimmed = string.gsub(trimmed, "%s+$", "")
    if trimmed == "" or string.sub(trimmed, 1, 1) == "#" then
        return nil
    end

    if string.match(trimmed, "^%|?%s*%-+%s*%|") then
        return nil
    end

    if string.sub(trimmed, 1, 1) == "|" then
        trimmed = string.sub(trimmed, 2)
    end
    if string.sub(trimmed, -1) == "|" then
        trimmed = string.sub(trimmed, 1, -2)
    end

    local parts = {}
    for field in string.gmatch(trimmed, "([^|]+)") do
        parts[#parts + 1] = string.gsub(string.gsub(field, "^%s+", ""), "%s+$", "")
    end

    if #parts < 2 then
        return nil
    end

    if string.lower(parts[1] or "") == "displayname" and string.lower(parts[2] or "") == "fullname" then
        return nil
    end

    return {
        displayName = parts[1],
        fullName = parts[2],
        rank = parts[3],
        mainRole = parts[4],
        multiplier = tonumber(parts[5]),
        wins = tonumber(parts[6]),
        authority = parts[7],
    }
end

local function parseAuthorityFlag(value)
    local normalized = string.lower(string.gsub(tostring(value or ""), "%s+", ""))
    if normalized == "" then
        return nil
    end
    if normalized == "1" or normalized == "true" or normalized == "yes" or normalized == "y" or normalized == "authority" then
        return true
    end
    if normalized == "0" or normalized == "false" or normalized == "no" or normalized == "n" then
        return false
    end
    return nil
end

local function ensureRosterEditorStore()
    if type(savedGlobal) ~= "table" then
        return {}
    end
    if type(savedGlobal.rosterEditor) ~= "table" then
        savedGlobal.rosterEditor = {
            rawText = "",
            lastPlan = nil,
            updatedAt = 0,
        }
    end
    return savedGlobal.rosterEditor
end

function RosterEditor:BuildPlan(rawText)
    if type(rawText) ~= "string" or rawText == "" then
        return false, "Roster text is empty"
    end

    local rows = {}
    local authorityAdds = {}
    local authorityColumnSeen = false

    for line in string.gmatch(rawText, "([^\r\n]+)") do
        local member = parseRosterTextRow(line)
        if member then
            local name, realm = splitFullName(member.fullName)
            local key = makePlayerKey(name or member.displayName or "unknown", realm or "")
            if key == "" then
                key = "unknown-" .. tostring(#rows + 1)
            end

            local canonicalFullName = buildCanonicalFullName(name or member.displayName or key, realm or "")
            rows[#rows + 1] = {
                key = key,
                displayName = member.displayName or key,
                fullName = canonicalFullName ~= "" and canonicalFullName or (member.fullName or key),
                name = name or member.displayName or key,
                realm = realm or "",
                rank = member.rank,
                mainRole = member.mainRole,
                multiplier = member.multiplier,
                wins = member.wins,
                authorityFlag = parseAuthorityFlag(member.authority),
            }

            if rows[#rows].authorityFlag ~= nil then
                authorityColumnSeen = true
                if rows[#rows].authorityFlag and not isAuthorityKey(key) then
                    authorityAdds[#authorityAdds + 1] = rows[#rows].fullName
                end
            end
        end
    end

    if #rows == 0 then
        return false, "No valid roster rows found"
    end

    table.sort(authorityAdds)

    local plan = {
        rows = rows,
        importedCount = #rows,
        authorityColumnSeen = authorityColumnSeen,
        authorityAdds = authorityAdds,
    }

    local store = ensureRosterEditorStore()
    store.rawText = rawText
    store.lastPlan = plan
    store.updatedAt = time()

    return true, plan
end

function RosterEditor:ApplyPlan(plan, applyAuthorityChanges)
    if type(plan) ~= "table" or type(plan.rows) ~= "table" or #plan.rows == 0 then
        return false, "No valid roster import plan"
    end

    local authorityColumnSeen = false
    for i = 1, #plan.rows do
        local row = plan.rows[i]
        local key = row.key

        saved.roster[key] = saved.roster[key] or {}
        saved.roster[key].displayName = row.displayName
        saved.roster[key].fullName = row.fullName
        saved.roster[key].name = row.name
        saved.roster[key].realm = row.realm
        saved.roster[key].rank = row.rank
        saved.roster[key].mainRole = row.mainRole

        saved.weights[key] = row.multiplier or saved.weights[key] or 1.0
        saved.wins[key] = row.wins or saved.wins[key] or 0

        if applyAuthorityChanges and row.authorityFlag ~= nil then
            authorityColumnSeen = true
            saved.authorityChain = saved.authorityChain or {}
            if row.authorityFlag then
                saved.authorityChain[key] = row.fullName or key
            end
        end
    end

    addRecentEvent("roster", string.format("Imported %d roster members", plan.importedCount or #plan.rows), nil)
    if authorityColumnSeen and applyAuthorityChanges then
        addRecentEvent("sync", "Authority chain updated from roster import", nil)
    end
    touchPersistentState("roster_import")

    local selfKey = select(1, getSelfKey())
    if isRaidLeader() or isAuthorityKey(selfKey) then
        sendSnapshot()
    end

    if applyAuthorityChanges then
        return true, string.format("Imported %d members with authority changes", plan.importedCount or #plan.rows)
    end

    return true, string.format("Imported %d members", plan.importedCount or #plan.rows)
end

local function validateRosterText(rawText)
    local ok, result = RosterEditor:BuildPlan(rawText)
    if not ok then
        return false, result
    end
    return true, string.format("Valid roster text (%d members)", result.importedCount or 0)
end

local function analyzeRosterImportText(rawText)
    local ok, result = RosterEditor:BuildPlan(rawText)
    if not ok then
        return false, result
    end
    return true, {
        importedCount = result.importedCount,
        authorityColumnSeen = result.authorityColumnSeen,
        authorityAdds = result.authorityAdds,
    }
end

local function importRosterFromText(rawText, applyAuthorityChanges)
    local ok, planOrMessage = RosterEditor:BuildPlan(rawText)
    if not ok then
        return false, planOrMessage
    end
    return RosterEditor:ApplyPlan(planOrMessage, applyAuthorityChanges)
end

local function tryResolvePlayerKey(input)
    input = input or ""
    input = string.lower(string.gsub(input, "%s+", ""))
    if input == "" then return nil end

    if saved.roster[input] then
        return input
    end

    for key, item in pairs(saved.roster or {}) do
        local display = string.lower(string.gsub(item.displayName or "", "%s+", ""))
        if display == input then
            return key
        end
    end

    return nil
end

local function printWeightSummary()
    print("|cff00ff00DetentionRolls:|r Current penalties")
    local rows = {}
    for key, mult in pairs(saved.weights or {}) do
        rows[#rows + 1] = {
            key = key,
            mult = tonumber(mult) or 1.0,
            wins = tonumber((saved.wins or {})[key]) or 0,
            display = (saved.roster[key] and saved.roster[key].displayName) or key,
        }
    end

    table.sort(rows, function(a, b)
        if a.mult == b.mult then
            return a.display < b.display
        end
        return a.mult < b.mult
    end)

    for i = 1, math.min(20, #rows) do
        local row = rows[i]
        print(string.format("|cffffff00%s|r -> %.2f (%d wins)", row.display, row.mult, row.wins))
    end
end

local function createImportFrame(parent)
    if importFrame then return importFrame end

    importFrameBlocker = CreateFrame("Frame", "DR_ImportBlocker", UIParent)
    importFrameBlocker:SetAllPoints(UIParent)
    importFrameBlocker:SetFrameStrata("FULLSCREEN_DIALOG")
    importFrameBlocker:EnableMouse(true)
    importFrameBlocker:Hide()

    local panel = CreateFrame("Frame", "DR_ImportFrame", UIParent, "BackdropTemplate")
    panel:SetSize(760, 640)
    panel:SetPoint("CENTER")
    panel:SetFrameStrata("FULLSCREEN_DIALOG")
    panel:SetClampedToScreen(true)
    panel:SetMovable(true)
    panel:EnableMouse(true)
    panel:RegisterForDrag("LeftButton")
    panel:SetScript("OnDragStart", startWindowDrag)
    panel:SetScript("OnDragStop", stopWindowDrag)
    panel:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
        edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
        tile = true,
        tileSize = 32,
        edgeSize = 32,
        insets = { left = 11, right = 12, top = 12, bottom = 11 },
    })

    local title = panel:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    title:SetPoint("TOP", 0, -14)
    title:SetText("Roster Text Import")

    local hint = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    hint:SetPoint("TOP", title, "BOTTOM", 0, -6)
    hint:SetText("Paste plain text or markdown table rows, then Validate or Import")

    local scroll = CreateFrame("ScrollFrame", "DR_ImportScroll", panel, "UIPanelScrollFrameTemplate")
    scroll:SetPoint("TOPLEFT", 18, -50)
    scroll:SetPoint("BOTTOMRIGHT", -36, 106)
    scroll:EnableMouse(true)
    scroll:EnableMouseWheel(true)

    local edit = CreateFrame("EditBox", "DR_ImportEdit", scroll)
    edit:SetMultiLine(true)
    edit:SetAutoFocus(false)
    edit:EnableMouse(true)
    edit:EnableKeyboard(true)
    edit:SetFontObject(ChatFontNormal)
    edit:SetMaxLetters(0)
    edit:SetTextInsets(6, 6, 6, 6)
    edit:SetWidth(680)
    edit:SetHeight(500)

    local function focusImportEditBox()
        if panel:IsShown() then
            edit:SetFocus()
            edit:HighlightText(0, 0)
        end
    end

    edit:SetScript("OnTextChanged", function(self)
        local textHeight = self:GetStringHeight() or 0
        local contentHeight = math.max(scroll:GetHeight(), textHeight + 28)
        self:SetHeight(contentHeight)
    end)
    edit:SetScript("OnEscapePressed", function(self)
        self:ClearFocus()
    end)
    edit:SetScript("OnMouseDown", focusImportEditBox)
    edit:SetScript("OnMouseUp", focusImportEditBox)

    scroll:SetScript("OnMouseDown", focusImportEditBox)
    scroll:SetScript("OnMouseWheel", function(self, delta)
        local current = self:GetVerticalScroll()
        local minScroll = 0
        local maxScroll = math.max(0, (edit:GetHeight() or 0) - self:GetHeight())
        local nextScroll = current - (delta * 32)
        if nextScroll < minScroll then nextScroll = minScroll end
        if nextScroll > maxScroll then nextScroll = maxScroll end
        self:SetVerticalScroll(nextScroll)
    end)
    scroll:SetScrollChild(edit)

    local status = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
    status:SetPoint("BOTTOMLEFT", 18, 62)
    status:SetText("Ready")

    local preview = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    preview:SetPoint("BOTTOMLEFT", 18, 82)
    preview:SetPoint("BOTTOMRIGHT", -18, 82)
    preview:SetJustifyH("LEFT")
    preview:SetJustifyV("BOTTOM")
    preview:SetText("Authority preview: no pending analysis")

    local function updateImportPreview(result)
        if type(result) ~= "table" then
            preview:SetText("Authority preview: unavailable")
            return
        end

        if not result.authorityColumnSeen then
            preview:SetText(string.format("Authority preview: roster only (%d members, no authority column)", result.importedCount or 0))
            return
        end

        local adds = #((result.authorityAdds) or {})
        local addText = adds > 0 and table.concat(result.authorityAdds, ", ") or "none"
        preview:SetText(string.format("Authority preview: +%d [%s]", adds, addText))
    end

    local exportBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    exportBtn:SetSize(90, 22)
    exportBtn:SetPoint("BOTTOMLEFT", 120, 20)
    exportBtn:SetText("Export Live")
    exportBtn:SetScript("OnClick", function()
        edit:SetText(buildLiveRosterExportText())
        edit:SetCursorPosition(0)
        edit:HighlightText(0)
        focusImportEditBox()
        status:SetText("Live roster text exported to textbox")
        local ok, result = analyzeRosterImportText(edit:GetText() or "")
        if ok then updateImportPreview(result) end
    end)

    local validateBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    validateBtn:SetSize(90, 22)
    validateBtn:SetPoint("BOTTOMLEFT", 216, 20)
    validateBtn:SetText("Validate")
    validateBtn:SetScript("OnClick", function()
        local ok, result = analyzeRosterImportText(edit:GetText() or "")
        if ok then
            status:SetText(string.format("Valid roster text (%d members)", result.importedCount or 0))
            updateImportPreview(result)
        else
            status:SetText(result or "Invalid roster text")
            preview:SetText("Authority preview: unavailable")
        end
    end)

    local importRosterBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    importRosterBtn:SetSize(110, 22)
    importRosterBtn:SetPoint("BOTTOMLEFT", 312, 20)
    importRosterBtn:SetText("Import Roster")
    importRosterBtn:SetScript("OnClick", function()
        local success, message = importRosterFromText(edit:GetText() or "", false)
        if success then
            status:SetText(message)
            print("|cff00ff00DetentionRolls:|r " .. message)
        else
            status:SetText(message or "Import failed")
            print("|cffff0000DetentionRolls:|r " .. (message or "Import failed"))
        end
        refreshTransparencyPanel()
        if contextMenu and contextMenu.RefreshAuthorityWidgets then
            contextMenu:RefreshAuthorityWidgets()
        end
    end)

    local importAuthorityBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    importAuthorityBtn:SetSize(150, 22)
    importAuthorityBtn:SetPoint("BOTTOMLEFT", 428, 20)
    importAuthorityBtn:SetText("Import Roster + Auth")
    importAuthorityBtn:SetScript("OnClick", function()
        local selfKey = select(1, getSelfKey())
        if isInRaid() and not (isRaidLeader() or isAuthorityKey(selfKey)) then
            status:SetText("Only authority members can import authority changes")
            print("|cffff0000DetentionRolls:|r Only authority members can import authority changes.")
            return
        end

        local ok, result = analyzeRosterImportText(edit:GetText() or "")
        if not ok then
            status:SetText(result or "Invalid roster text")
            preview:SetText("Authority preview: unavailable")
            return
        end

        updateImportPreview(result)
        StaticPopupDialogs["DR_CONFIRM_ROSTER_AUTH_IMPORT"] = {
            text = string.format(
                "Import %d roster members and add authority changes?\nAdd: %d\nExisting entries are left unchanged.",
                result.importedCount or 0,
                #(result.authorityAdds or {})
            ),
            button1 = "Import",
            button2 = "Cancel",
            OnAccept = function()
                local success, message = importRosterFromText(edit:GetText() or "", true)
                if success then
                    status:SetText(message)
                    print("|cff00ff00DetentionRolls:|r " .. message)
                else
                    status:SetText(message or "Import failed")
                    print("|cffff0000DetentionRolls:|r " .. (message or "Import failed"))
                end
                refreshTransparencyPanel()
                if contextMenu and contextMenu.RefreshAuthorityWidgets then
                    contextMenu:RefreshAuthorityWidgets()
                end
            end,
            timeout = 0,
            whileDead = true,
            hideOnEscape = true,
            preferredIndex = 3,
        }
        StaticPopup_Show("DR_CONFIRM_ROSTER_AUTH_IMPORT")
    end)

    local closeBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate")
    closeBtn:SetSize(70, 22)
    closeBtn:SetPoint("BOTTOMRIGHT", -18, 20)
    closeBtn:SetText("Close")
    closeBtn:SetScript("OnClick", function()
        panel:Hide()
    end)

    panel:SetScript("OnMouseDown", focusImportEditBox)
    panel.edit = edit
    panel.status = status
    panel.preview = preview
    panel:SetScript("OnShow", function()
        if importFrameBlocker then importFrameBlocker:Show() end
        C_Timer.After(0, focusImportEditBox)
    end)
    panel:SetScript("OnHide", function()
        if importFrameBlocker then importFrameBlocker:Hide() end
    end)
    panel:Hide()

    importFrame = panel
    return panel
end

local function createTransparencyPanel(parent, leftInset, startY, panelWidth, panelHeight)
    local panel = CreateFrame("Frame", nil, parent, "BackdropTemplate")
    local width = panelWidth or 312
    local height = panelHeight or 210
    local lineHeight = 10
    local visibleLines = math.max(10, math.floor((height - 18) / lineHeight))
    local flexibleLines = math.max(4, visibleLines - 7)

    panel:SetPoint("TOPLEFT", leftInset or 14, startY)
    panel:SetSize(width, height)
    panel:SetBackdrop({
        bgFile = "Interface\\Buttons\\WHITE8x8",
        edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
        tile = false,
        edgeSize = 12,
        insets = { left = 3, right = 3, top = 3, bottom = 3 },
    })
    panel:SetBackdropColor(0, 0, 0, 0.45)

    local lines = {}
    for i = 1, visibleLines do
        local f = panel:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        f:SetPoint("TOPLEFT", 8, -8 - ((i - 1) * lineHeight))
        f:SetJustifyH("LEFT")
        f:SetWidth(width - 16)
        f:SetText("")
        lines[i] = f
    end

    panel.lines = lines
    panel.maxPenaltyRows = math.max(2, math.floor(flexibleLines / 2))
    panel.maxEventRows = math.max(2, flexibleLines - panel.maxPenaltyRows)
    panel.Refresh = refreshTransparencyPanel

    transparencyPanel = panel
    refreshTransparencyPanel()
    return panel
end

local function createContextMenu()
    if contextMenu then return contextMenu end

    local menu = CreateFrame("Frame", "DR_Settings", UIParent, "BackdropTemplate")
    local menuWidth = 590
    local menuHeight = 860
    local scrollInsetLeft = 22
    local scrollInsetRight = 36
    local scrollInsetTop = 34
    local scrollInsetBottom = 24
    local contentTop = -8
    local contentLeft = 0
    local columnGap = 24
    local columnWidth = 238
    local contentWidth = (menuWidth - scrollInsetLeft - scrollInsetRight) - 12
    local rightColumnLeft = contentLeft + columnWidth + columnGap
    local sectionGap = 18
    local buttonHeight = 24
    local buttonSpacing = 32
    local sliderHeight = 66

    menu:SetSize(menuWidth, menuHeight)
    menu:SetPoint("CENTER")
    menu:SetFrameStrata("DIALOG")
    menu:SetClampedToScreen(true)
    menu:SetBackdrop({
        bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
        edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
        tile = true,
        tileSize = 32,
        edgeSize = 32,
        insets = { left = 11, right = 12, top = 12, bottom = 11 },
    })
    menu:Hide()

    menu:SetMovable(true)
    menu:EnableMouse(true)
    menu:RegisterForDrag("LeftButton")
    menu:SetScript("OnDragStart", startWindowDrag)
    menu:SetScript("OnDragStop", stopWindowDrag)
    menu:SetScript("OnHide", function()
        if importFrame then
            importFrame:Hide()
        end
    end)
    _G.UISpecialFrames = _G.UISpecialFrames or {}
    table.insert(_G.UISpecialFrames, "DR_Settings")
    applyWindowLockState(menu)

    local title = menu:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    title:SetPoint("TOP", 0, -15)
    title:SetText("DetentionRolls Settings")

    local closeBtn = CreateFrame("Button", nil, menu, "UIPanelCloseButton")
    closeBtn:SetPoint("TOPRIGHT", menu, "TOPRIGHT", -5, -5)

    local scrollFrame = CreateFrame("ScrollFrame", nil, menu, "UIPanelScrollFrameTemplate")
    scrollFrame:SetPoint("TOPLEFT", menu, "TOPLEFT", scrollInsetLeft, -scrollInsetTop)
    scrollFrame:SetPoint("BOTTOMRIGHT", menu, "BOTTOMRIGHT", -scrollInsetRight, scrollInsetBottom)
    scrollFrame:EnableMouseWheel(true)

    local scrollChild = CreateFrame("Frame", nil, scrollFrame)
    scrollChild:SetSize(contentWidth, 1)
    scrollFrame:SetScrollChild(scrollChild)

    scrollFrame:SetScript("OnMouseWheel", function(self, delta)
        local current = self:GetVerticalScroll()
        local step = 28
        local maxScroll = math.max(0, (scrollChild:GetHeight() or 0) - self:GetHeight())
        local nextValue = current - (delta * step)
        if nextValue < 0 then nextValue = 0 end
        if nextValue > maxScroll then nextValue = maxScroll end
        self:SetVerticalScroll(nextValue)
    end)

    local function stepPrecision(step)
        local decimals = string.match(tostring(step or 0), "%.(%d+)")
        return decimals and #decimals or 0
    end

    local function formatSliderValue(value, precision)
        if precision <= 0 then
            return tostring(math.floor((tonumber(value) or 0) + 0.5))
        end
        return string.format("%." .. precision .. "f", tonumber(value) or 0)
    end

    local function clampSliderValue(value, minValue, maxValue, step, precision)
        local normalized = tonumber(value) or minValue
        if normalized < minValue then normalized = minValue end
        if normalized > maxValue then normalized = maxValue end

        if step and step > 0 then
            normalized = minValue + (math.floor(((normalized - minValue) / step) + 0.5) * step)
        end

        local scale = 10 ^ precision
        normalized = math.floor((normalized * scale) + 0.5) / scale

        if normalized < minValue then normalized = minValue end
        if normalized > maxValue then normalized = maxValue end
        return normalized
    end

    local function createSection(text, left, top)
        local section = CreateFrame("Frame", nil, scrollChild)
        section.anchorTop = top
        section:SetPoint("TOPLEFT", left, top)
        section:SetSize(columnWidth, 1)
        section.cursorY = -26

        local header = section:CreateFontString(nil, "OVERLAY", "GameFontNormalLeft")
        header:SetPoint("TOPLEFT", 0, 0)
        header:SetText("|cFFFFD700" .. text .. "|r")

        local divider = section:CreateTexture(nil, "ARTWORK")
        divider:SetColorTexture(1, 0.84, 0, 0.2)
        divider:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, -4)
        divider:SetPoint("TOPRIGHT", section, "TOPRIGHT", 0, -18)
        divider:SetHeight(1)

        function section:Advance(height)
            self.cursorY = self.cursorY - height
            self:SetHeight(-self.cursorY)
        end

        function section:BottomY()
            return self.anchorTop + self.cursorY
        end

        return section
    end

    local function addCheck(section, text, key, onClick)
        local cb = CreateFrame("CheckButton", nil, section, "UICheckButtonTemplate")
        cb:SetPoint("TOPLEFT", 0, section.cursorY)
        cb:SetChecked(saved[key])

        local label = cb.text or cb.Text
        if label then
            label:SetText(text)
            label:SetWidth(columnWidth - 28)
            label:SetJustifyH("LEFT")
        end

        cb:SetScript("OnClick", function(self)
            saved[key] = self:GetChecked()
            if onClick then onClick(self:GetChecked()) end
            updateFrameVisuals()
            updateToggleButtonVisuals()
            refreshTransparencyPanel()
        end)

        section:Advance(26)
        return cb
    end

    local function addButton(section, text, onClick)
        local button = CreateFrame("Button", nil, section, "UIPanelButtonTemplate")
        button:SetSize(columnWidth, buttonHeight)
        button:SetPoint("TOPLEFT", 0, section.cursorY)
        button:SetText(text)
        button:SetScript("OnClick", onClick)
        section:Advance(buttonSpacing)
        return button
    end

    local function addSlider(section, name, minValue, maxValue, step, value, onChange)
        local precision = stepPrecision(step)
        local holder = CreateFrame("Frame", nil, section)
        holder:SetPoint("TOPLEFT", 0, section.cursorY)
        holder:SetSize(columnWidth, sliderHeight)

        local label = holder:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        label:SetPoint("TOPLEFT", 0, 0)
        label:SetWidth(columnWidth - 74)
        label:SetJustifyH("LEFT")
        label:SetText(name)

        local valueBox = CreateFrame("EditBox", nil, holder, "InputBoxTemplate")
        valueBox:SetAutoFocus(false)
        valueBox:SetSize(58, 20)
        valueBox:SetPoint("TOPRIGHT", -6, 2)
        valueBox:SetMaxLetters(7)
        valueBox:SetJustifyH("CENTER")

        local slider = CreateFrame("Slider", nil, holder, "OptionsSliderTemplate")
        slider:SetPoint("TOPLEFT", 0, -20)
        slider:SetWidth(columnWidth - 8)
        slider:SetMinMaxValues(minValue, maxValue)
        slider:SetValueStep(step)
        slider:SetObeyStepOnDrag(true)

        if slider.Text then
            slider.Text:SetText("")
            slider.Text:Hide()
        end
        if slider.Low then
            slider.Low:ClearAllPoints()
            slider.Low:SetPoint("TOPLEFT", slider, "BOTTOMLEFT", 0, -2)
            slider.Low:SetText(formatSliderValue(minValue, precision))
        end
        if slider.High then
            slider.High:ClearAllPoints()
            slider.High:SetPoint("TOPRIGHT", slider, "BOTTOMRIGHT", 0, -2)
            slider.High:SetText(formatSliderValue(maxValue, precision))
        end

        local function setBoxValue(newValue)
            valueBox:SetText(formatSliderValue(newValue, precision))
        end

        local function commitBoxValue()
            local rawText = string.gsub(valueBox:GetText() or "", ",", ".")
            local typedValue = tonumber(rawText)
            if not typedValue then
                setBoxValue(slider:GetValue())
                return
            end

            local normalized = clampSliderValue(typedValue, minValue, maxValue, step, precision)
            slider._settingFromBox = true
            slider:SetValue(normalized)
            slider._settingFromBox = nil
            setBoxValue(normalized)
            onChange(normalized)
            refreshTransparencyPanel()
        end

        valueBox:SetScript("OnEnterPressed", function(self)
            commitBoxValue()
            self:ClearFocus()
        end)
        valueBox:SetScript("OnEscapePressed", function(self)
            setBoxValue(slider:GetValue())
            self:ClearFocus()
        end)
        valueBox:SetScript("OnEditFocusLost", function()
            commitBoxValue()
        end)

        slider:SetValue(clampSliderValue(value, minValue, maxValue, step, precision))
        setBoxValue(slider:GetValue())

        slider:SetScript("OnValueChanged", function(self, newValue)
            local normalized = clampSliderValue(newValue, minValue, maxValue, step, precision)
            setBoxValue(normalized)
            if self._settingFromBox then
                return
            end
            onChange(normalized)
            refreshTransparencyPanel()
        end)

        section:Advance(sliderHeight)
        return slider, valueBox
    end

    local function addTextInput(section, labelText, initialValue)
        local holder = CreateFrame("Frame", nil, section)
        holder:SetPoint("TOPLEFT", 0, section.cursorY)
        holder:SetSize(columnWidth, 42)

        local label = holder:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        label:SetPoint("TOPLEFT", 0, 0)
        label:SetText(labelText)

        local input = CreateFrame("EditBox", nil, holder, "InputBoxTemplate")
        input:SetAutoFocus(false)
        input:SetSize(columnWidth - 8, 20)
        input:SetPoint("TOPLEFT", 0, -16)
        input:SetText(initialValue or "")
        input:SetCursorPosition(0)

        section:Advance(48)
        return input, label
    end

    local leftY = contentTop
    local rightY = contentTop

    local generalSection = createSection("General", contentLeft, leftY)
    addCheck(generalSection, "Minimize windows in combat", "minimizeInCombat", function(enabled)
        saved.hideInCombat = enabled
    end)
    addCheck(generalSection, "Auto open on boss loot", "autoOpen")
    addCheck(generalSection, "Auto close when session is done", "autoClose")
    addCheck(generalSection, "Auto trade awarded items", "autoTrade")
    addCheck(generalSection, "Show compact raid leaderboard", "showCompactLeaderboard", function(enabled)
        if leaderboardFrame then
            if enabled then
                leaderboardFrame:SetShown(canAutoOpenLootWindows())
            else
                leaderboardFrame:Hide()
            end
        end
    end)
    leftY = generalSection:BottomY() - sectionGap

    local diceSection = createSection("Dice Button", contentLeft, leftY)
    addCheck(diceSection, "Pin to minimap", "pinnedToMinimap", function(enabled)
        if not enabled then
            saved.toggleButtonPos = nil
            toggleButton:ClearAllPoints()
            toggleButton:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
            print("|cff00ff00DetentionRolls:|r Dice button reset to center.")
        else
            updateToggleButtonPosition()
        end
    end)

    local btnStrataToggle = addButton(diceSection, "Button Layer: " .. (saved.toggleStrata or "HIGH"), function(self)
        local s = saved.toggleStrata
        saved.toggleStrata = (s == "BACKGROUND" and "MEDIUM") or (s == "MEDIUM" and "HIGH") or (s == "HIGH" and "DIALOG") or "BACKGROUND"
        self:SetText("Button Layer: " .. saved.toggleStrata)
        updateToggleButtonVisuals()
    end)

    addSlider(diceSection, "Button Inactive Alpha", 0, 1, 0.1, saved.buttonAlpha or 1, function(newValue)
        saved.buttonAlpha = math.floor(newValue * 10 + 0.5) / 10
        updateToggleButtonVisuals()
    end)
    leftY = diceSection:BottomY() - sectionGap

    local windowSection = createSection("Roll Window", contentLeft, leftY)
    addCheck(windowSection, "Auto-hide when mouse leaves", "hoverMode")
    addCheck(windowSection, "Lock window movement", "locked")

    addSlider(windowSection, "Auto-hide Alpha", 0, 1, 0.1, saved.frameAlpha or 0, function(newValue)
        saved.frameAlpha = math.floor(newValue * 10 + 0.5) / 10
        updateFrameVisuals()
    end)

    local btnStrataFrame = addButton(windowSection, "Window Layer: " .. (saved.strata or "MEDIUM"), function(self)
        local s = saved.strata
        saved.strata = (s == "BACKGROUND" and "MEDIUM") or (s == "MEDIUM" and "HIGH") or (s == "HIGH" and "DIALOG") or "BACKGROUND"
        self:SetText("Window Layer: " .. saved.strata)
        updateFrameVisuals()
    end)

    addSlider(windowSection, "Window Scale", 0.5, 2.0, 0.1, saved.scale or 1.0, function(newValue)
        saved.scale = math.floor(newValue * 10 + 0.5) / 10
        updateFrameVisuals()
    end)
    leftY = windowSection:BottomY() - sectionGap

    local authoritySection = createSection("Authority", contentLeft, leftY)

    local authorityIntro = authoritySection:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    authorityIntro:SetPoint("TOPLEFT", 0, authoritySection.cursorY)
    authorityIntro:SetWidth(columnWidth)
    authorityIntro:SetJustifyH("LEFT")
    authorityIntro:SetJustifyV("TOP")
    authorityIntro:SetText("Raid Leader manages which players are allowed to answer sync requests. Removal stays manual.")
    authoritySection:Advance(44)

    local authorityInput = addTextInput(authoritySection, "Add authority member (name-realm)", "")
    authorityInput:SetMaxLetters(80)

    local authorityAddButton = CreateFrame("Button", nil, authoritySection, "UIPanelButtonTemplate")
    authorityAddButton:SetSize(columnWidth, buttonHeight)
    authorityAddButton:SetPoint("TOPLEFT", 0, authoritySection.cursorY)
    authorityAddButton:SetText("Add Authority Member")
    authoritySection:Advance(buttonSpacing)

    local targetAuthorityButton = CreateFrame("Button", nil, authoritySection, "UIPanelButtonTemplate")
    targetAuthorityButton:SetSize(columnWidth, buttonHeight)
    targetAuthorityButton:SetPoint("TOPLEFT", 0, authoritySection.cursorY)
    targetAuthorityButton:SetText("Add Current Target")
    authoritySection:Advance(buttonSpacing)

    local authorityStatus = authoritySection:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
    authorityStatus:SetPoint("TOPLEFT", 0, authoritySection.cursorY)
    authorityStatus:SetWidth(columnWidth)
    authorityStatus:SetJustifyH("LEFT")
    authorityStatus:SetJustifyV("TOP")
        authorityStatus:SetText("Authority list ready. Use Remove for manual cleanup.")
    authoritySection:Advance(32)

    local authorityRows = {}
    for i = 1, 6 do
        local row = CreateFrame("Frame", nil, authoritySection)
        row:SetPoint("TOPLEFT", 0, authoritySection.cursorY)
        row:SetSize(columnWidth, 38)

        row.bg = row:CreateTexture(nil, "BACKGROUND")
        row.bg:SetAllPoints()
        row.bg:SetColorTexture(1, 1, 1, 0.04)

        row.name = row:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
        row.name:SetPoint("TOPLEFT", 0, 0)
        row.name:SetWidth(columnWidth - 64)
        row.name:SetJustifyH("LEFT")

        row.meta = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        row.meta:SetPoint("TOPLEFT", 0, -16)
        row.meta:SetWidth(columnWidth - 64)
        row.meta:SetJustifyH("LEFT")

        row.state = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        row.state:SetPoint("TOPRIGHT", -58, 0)
        row.state:SetWidth(56)
        row.state:SetJustifyH("RIGHT")

        row.remove = CreateFrame("Button", nil, row, "UIPanelButtonTemplate")
        row.remove:SetSize(56, 18)
        row.remove:SetPoint("BOTTOMRIGHT", 0, 0)
        row.remove:SetText("Remove")

        authorityRows[i] = row
        authoritySection:Advance(42)
    end

    local function setAuthorityStatusMessage(text, r, g, b)
        authorityStatus:SetText(text or "")
        authorityStatus:SetTextColor(r or 1, g or 0.82, b or 0.2)
    end

    local function confirmAuthorityChange(action, fullName)
        local popupKey = action == "remove" and "DR_CONFIRM_AUTHORITY_REMOVE" or "DR_CONFIRM_AUTHORITY_ADD"
        StaticPopupDialogs[popupKey] = {
            text = action == "remove"
                and ("Remove authority member?\n" .. tostring(fullName or ""))
                or ("Add authority member?\n" .. tostring(fullName or "")),
            button1 = action == "remove" and "Remove" or "Add",
            button2 = "Cancel",
            OnAccept = function()
                local ok, normalizedKey
                if action == "remove" then
                    ok, normalizedKey = removeAuthorityMember(fullName)
                    if ok then
                        broadcastAuthorityStatus()
                        sendSnapshot()
                        setAuthorityStatusMessage("Removed authority: " .. tostring(fullName), 0.25, 1.0, 0.25)
                    else
                        setAuthorityStatusMessage("Could not remove authority member.", 1.0, 0.35, 0.35)
                    end
                else
                    ok, normalizedKey = addAuthorityMember(fullName)
                    if ok then
                        broadcastAuthorityStatus()
                        sendSnapshot()
                        authorityInput:SetText("")
                        setAuthorityStatusMessage("Added authority: " .. tostring(fullName), 0.25, 1.0, 0.25)
                    else
                        setAuthorityStatusMessage("Could not add authority member. Use name-realm.", 1.0, 0.35, 0.35)
                    end
                end

                if menu.RefreshAuthorityWidgets then
                    menu:RefreshAuthorityWidgets()
                end
                refreshTransparencyPanel()
            end,
            timeout = 0,
            whileDead = true,
            hideOnEscape = true,
            preferredIndex = 3,
        }
        StaticPopup_Show(popupKey)
    end

    authorityAddButton:SetScript("OnClick", function()
        if not isRaidLeader() then
            setAuthorityStatusMessage("Only the Raid Leader can edit the authority chain.", 1.0, 0.35, 0.35)
            return
        end

        local fullName = string.gsub(authorityInput:GetText() or "", "^%s+", "")
        fullName = string.gsub(fullName, "%s+$", "")
        local normalizedKey = normalizeFullNameToKey(fullName)
        if fullName == "" or normalizedKey == "" or not string.find(fullName, "%-", 1, true) then
            setAuthorityStatusMessage("Enter a valid name-realm before adding.", 1.0, 0.35, 0.35)
            return
        end

        confirmAuthorityChange("add", fullName)
    end)

    targetAuthorityButton:SetScript("OnClick", function()
        if not isRaidLeader() then
            setAuthorityStatusMessage("Only the Raid Leader can edit the authority chain.", 1.0, 0.35, 0.35)
            return
        end

        local fullName = getTargetPlayerFullName()
        if not fullName then
            setAuthorityStatusMessage("Target a player first to add them quickly.", 1.0, 0.35, 0.35)
            return
        end

        authorityInput:SetText(fullName)
        confirmAuthorityChange("add", fullName)
    end)

    authorityInput:SetScript("OnEnterPressed", function()
        authorityAddButton:Click()
    end)

    function menu:RefreshAuthorityWidgets()
        local rows = getAuthorityRows()
        local editable = isRaidLeader()

        authorityInput:SetEnabled(editable)
        authorityAddButton:SetEnabled(editable)
        targetAuthorityButton:SetEnabled(editable)

        if #rows == 0 then
            setAuthorityStatusMessage(editable and "No authority members configured yet. Use Remove for manual cleanup." or "No authority members available to view.", 1.0, 0.82, 0.2)
        elseif not editable then
            setAuthorityStatusMessage("View only. Raid Leader controls authority changes.", 1.0, 0.82, 0.2)
        end

        for i = 1, #authorityRows do
            local rowData = rows[i]
            local row = authorityRows[i]
            if rowData then
                local stateText, sr, sg, sb = formatAuthorityStateText(rowData.state, rowData.ageSeconds)
                row.name:SetText(rowData.fullName)
                row.meta:SetText(rowData.key)
                row.state:SetText(stateText)
                row.state:SetTextColor(sr, sg, sb)
                row.name:SetTextColor(sr, sg, sb)
                row.meta:SetTextColor(math.min(1, sr + 0.1), math.min(1, sg + 0.1), math.min(1, sb + 0.1))

                if rowData.state == "fresh" then
                    row.bg:SetColorTexture(0.10, 0.55, 0.14, 0.20)
                elseif rowData.state == "stale" then
                    row.bg:SetColorTexture(0.60, 0.45, 0.08, 0.20)
                else
                    row.bg:SetColorTexture(0.55, 0.10, 0.10, 0.16)
                end

                row.remove:SetShown(editable)
                row.remove:SetEnabled(editable)
                row:Show()

                row.remove:SetScript("OnClick", function()
                    if not isRaidLeader() then
                        setAuthorityStatusMessage("Only the Raid Leader can edit the authority chain.", 1.0, 0.35, 0.35)
                        return
                    end
                    confirmAuthorityChange("remove", rowData.fullName)
                end)
            else
                row.name:SetText("")
                row.meta:SetText("")
                row.state:SetText("")
                row.bg:SetColorTexture(1, 1, 1, 0.04)
                row.remove:Hide()
                row:Hide()
            end
        end
    end

    menu:RefreshAuthorityWidgets()
    leftY = authoritySection:BottomY() - sectionGap

    local fairnessSection = createSection("Fairness", rightColumnLeft, rightY)
    addCheck(fairnessSection, "Enable weighted fairness", "fairnessEnabled")
    addCheck(fairnessSection, "Priority same-item rolls by category", "categoryPriorityMode", function()
        refreshLootSessionWindow()
        refreshTransparencyPanel()
    end)
    addCheck(fairnessSection, "Auto pass for non-leaders", "autoPassNonLeader")
    addCheck(fairnessSection, "Enable fake loot test mode", "testMode")

    addSlider(fairnessSection, "Fake loot items per test", 1, 20, 1, saved.testLootCount or 6, function(newValue)
        saved.testLootCount = math.floor(newValue + 0.5)
    end)

    addSlider(fairnessSection, "Penalty on win (%)", 0, 80, 1, (saved.penaltyOnWin or 0.30) * 100, function(newValue)
        saved.penaltyOnWin = (math.floor(newValue + 0.5) / 100)
    end)

    addSlider(fairnessSection, "Recovery per boss (%)", 0, 50, 1, (saved.recoveryPerBoss or 0.05) * 100, function(newValue)
        saved.recoveryPerBoss = (math.floor(newValue + 0.5) / 100)
    end)

    addSlider(fairnessSection, "Minimum multiplier (%)", 1, 100, 1, (saved.minMultiplier or 0.40) * 100, function(newValue)
        saved.minMultiplier = (math.floor(newValue + 0.5) / 100)
    end)
    rightY = fairnessSection:BottomY() - sectionGap

    local rosterSection = createSection("Roster & Sync", rightColumnLeft, rightY)
    addButton(rosterSection, "Open Roster Text", function()
        local panel = createImportFrame(menu)
        if panel:IsShown() then
            panel:Hide()
        else
            panel:Show()
        end
    end)
    addButton(rosterSection, "Raid Sync", function()
        if isRaidLeader() then
            sendSnapshot()
            print("|cff00ff00DetentionRolls:|r Broadcasted full state snapshot.")
        else
            requestSnapshot()
            print("|cff00ff00DetentionRolls:|r Requested state snapshot from Raid Leader.")
        end
    end)
    addButton(rosterSection, "Save Now (/reload)", function()
        print("|cff00ff00DetentionRolls:|r Saving state and reloading UI.")
        if C_UI and C_UI.Reload then
            C_UI.Reload()
        elseif _G and _G["ReloadUI"] then
            _G["ReloadUI"]()
        end
    end)
    rightY = rosterSection:BottomY() - sectionGap

    local toolsSection = createSection("Raid Tools", rightColumnLeft, rightY)
    local leaderLootBtn = addButton(toolsSection, "Leader Auto Loot: " .. (saved.leaderAutoLootMode or "need_or_greed"), function(self)
        local mode = saved.leaderAutoLootMode or "need_or_greed"
        if mode == "off" then
            mode = "need_or_greed"
        elseif mode == "need_or_greed" then
            mode = "need"
        elseif mode == "need" then
            mode = "greed"
        elseif mode == "greed" then
            mode = "off"
        end
        saved.leaderAutoLootMode = mode
        self:SetText("Leader Auto Loot: " .. mode)
    end)

    addButton(toolsSection, "Open Loot Session", function()
        local panel = createLootSessionWindow()
        if panel:IsShown() then
            panel:Hide()
        else
            panel:Show()
        end
        refreshLootSessionWindow()
    end)

    addButton(toolsSection, "Open Active Rolls", function()
        local panel = leaderboardFrame or createCompactLeaderboardPopup()
        panel:Show()
        refreshLeaderboardPopup()
    end)

    addButton(toolsSection, "Add Fake Loot Item", function()
        if not saved.testMode then
            print("|cffffaa00DetentionRolls:|r Enable fake loot test mode first.")
            return
        end
        addFakeLootToSession()
    end)

    addButton(toolsSection, "Generate Fake Session", function()
        if not saved.testMode then
            print("|cffffaa00DetentionRolls:|r Enable fake loot test mode first.")
            return
        end
        local count = tonumber(saved.testLootCount) or 6
        for _ = 1, count do
            addFakeLootToSession()
        end
        print(string.format("|cff00ff00DetentionRolls:|r Generated %d fake loot items.", count))
    end)
    rightY = toolsSection:BottomY() - sectionGap

    local panelTop = math.min(leftY, rightY)
    local panelHeight = 152
    local panelBottom = panelTop - panelHeight
    createTransparencyPanel(scrollChild, contentLeft, panelTop, contentWidth - 8, panelHeight)

    local contentHeight = math.abs(panelBottom) + 12
    scrollChild:SetHeight(contentHeight)

    function menu:RefreshFairnessWidgets()
        refreshTransparencyPanel()
        refreshLeaderboardPopup()
        refreshLootSessionWindow()
        if self.RefreshAuthorityWidgets then
            self:RefreshAuthorityWidgets()
        end
    end

    contextMenu = menu
    return menu
end

local function toggleSettingsWindow()
    if not contextMenu then createContextMenu() end
    if contextMenu:IsShown() then
        contextMenu:Hide()
    else
        contextMenu:Show()
        if importFrame then importFrame:Hide() end
        contextMenu:RefreshFairnessWidgets()
    end
end

local function createMainFrame()
    frame = CreateFrame("Frame", "DR_Frame", UIParent, "BackdropTemplate")
    frame.buttons = {}

    frame.bg = frame:CreateTexture(nil, "BACKGROUND")
    frame.bg:SetAllPoints()

    frame:SetMovable(true)
    frame:RegisterForDrag("LeftButton")
    frame:SetScript("OnDragStart", startWindowDrag)
    frame:SetScript("OnDragStop", function(self)
        stopWindowDrag(self, saveFramePosition)
    end)

    frame:SetScript("OnEnter", function(self)
        if saved.hoverMode then
            UIFrameFadeIn(self, 0.2, self:GetAlpha(), 1)
        end
    end)

    frame:SetScript("OnLeave", function(self)
        if saved.hoverMode then
            UIFrameFadeOut(self, 0.5, 1, saved.frameAlpha or 0)
        end
    end)

    frame.title = frame:CreateFontString(nil, "OVERLAY", "GameFontNormal")
    local tFont, tSize = frame.title:GetFont()
    frame.title:SetFont(tFont, tSize * 1.3, "OUTLINE")
    frame.title:SetPoint("TOP", 0, -6)
    frame.title:SetText("DetentionRolls")

    for _, data in ipairs(rolls) do
        local btn = CreateFrame("Button", nil, frame)
        btn:SetSize(62, 22)

        local bg = btn:CreateTexture(nil, "BACKGROUND")
        bg:SetAllPoints()
        bg:SetColorTexture(0.1, 0.1, 0.1, 0.9)

        local bdr = btn:CreateTexture(nil, "BORDER")
        bdr:SetPoint("TOPLEFT", -3, 3)
        bdr:SetPoint("BOTTOMRIGHT", 3, -3)

        local ok = pcall(function() bdr:SetAtlas("weeklyreward-border", true) end)
        if not ok then
            bdr:SetColorTexture(0.5, 0.5, 0.5, 0.5)
        end

        btn.text = btn:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall")
        btn.text:SetPoint("CENTER")
        btn.text:SetText(data.name)

        if data.isGold then
            local bFont, bSize = btn.text:GetFont()
            btn.text:SetFont(bFont, bSize * 1.2, "OUTLINE")
            btn.text:SetTextColor(1, 0.84, 0)
        end

        btn:SetScript("OnClick", function()
            doRoll(data)
        end)

        btn:SetScript("OnEnter", function(self)
            local key = getSelfKey()
            local effMax = computeEffectiveMax(data.max, data.category, key)
            GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
            GameTooltip:SetText(data.name, 1, 1, 1)
            GameTooltip:AddLine(data.tooltipTitle or "", 0.8, 0.8, 0.8, true)
            GameTooltip:AddLine("Roll: 0-" .. data.max, 0, 1, 0)
            GameTooltip:AddLine("Effective: 0-" .. effMax, 0.3, 0.9, 1)
            GameTooltip:Show()
            if saved.hoverMode then
                UIFrameFadeIn(frame, 0.1, frame:GetAlpha(), 1)
            end
        end)

        btn:SetScript("OnLeave", function()
            GameTooltip:Hide()
            if saved.hoverMode then
                UIFrameFadeOut(frame, 0.5, 1, saved.frameAlpha or 0)
            end
        end)

        table.insert(frame.buttons, btn)
    end

    restoreFramePosition()
    updateFrameVisuals()
    UiAutomation:RegisterWindow(frame)
end

local function createToggleButton()
    toggleButton = CreateFrame("Button", "DR_Toggle", UIParent)
    toggleButton:SetSize(31, 31)
    toggleButton:SetFrameStrata(saved.toggleStrata or "HIGH")

    local background = toggleButton:CreateTexture(nil, "BACKGROUND")
    background:SetTexture("Interface\\Minimap\\UI-Minimap-Background")
    background:SetAllPoints()

    local icon = toggleButton:CreateTexture(nil, "ARTWORK")
    icon:SetTexture("Interface\\AddOns\\DetentionRolls\\Drolls.png")
    icon:SetTexCoord(0.10, 0.90, 0.10, 0.90)
    icon:SetPoint("TOPLEFT", 6, -6)
    icon:SetPoint("BOTTOMRIGHT", -6, 6)

    local border = toggleButton:CreateTexture(nil, "OVERLAY")
    border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
    border:SetAllPoints()

    local highlight = toggleButton:CreateTexture(nil, "HIGHLIGHT")
    highlight:SetTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
    highlight:SetBlendMode("ADD")
    highlight:SetAllPoints()

    toggleButton:RegisterForClicks("LeftButtonUp", "RightButtonUp")
    toggleButton:SetMovable(true)

    toggleButton:SetScript("OnMouseDown", function(self, btn)
        if btn == "LeftButton" then
            if saved.locked then
                return
            end
            if saved.pinnedToMinimap then
                self:SetScript("OnUpdate", onMinimapUpdate)
            else
                self:StartMoving()
            end
        end
    end)

    toggleButton:SetScript("OnMouseUp", function(self, btn)
        if btn == "LeftButton" then
            self:SetScript("OnUpdate", nil)
            if not saved.pinnedToMinimap then
                self:StopMovingOrSizing()
                local _, _, _, xOfs, yOfs = self:GetPoint()
                saved.toggleButtonPos = { x = xOfs, y = yOfs }
            end
        end
    end)

    toggleButton:SetScript("OnClick", function(_, btn)
        if btn == "RightButton" then
            toggleSettingsWindow()
        else
            if frame:IsShown() then frame:Hide() else frame:Show() end
        end
    end)

    toggleButton:SetScript("OnEnter", function(self)
        updateToggleButtonVisuals()
        GameTooltip:SetOwner(self, "ANCHOR_LEFT")
        GameTooltip:SetText("DetentionRolls", 1, 1, 1, 1, true)
        GameTooltip:AddLine("Left: Toggle roll window", 1, 1, 1)
        GameTooltip:AddLine("Left (hold): Move", 0.8, 0.8, 0.8)
        GameTooltip:AddLine("Right: Open settings", 1, 1, 1)
        GameTooltip:Show()
    end)

    toggleButton:SetScript("OnLeave", function()
        updateToggleButtonVisuals()
        GameTooltip_Hide()
    end)

    updateToggleButtonPosition()
    updateToggleButtonVisuals()
    toggleButton:SetMovable(canMoveWindows())
    toggleButton:Show()
end

SLASH_DETENTIONROLLS1 = "/detentionrolls"
SLASH_DETENTIONROLLS2 = "/dr"
SlashCmdList["DETENTIONROLLS"] = function(msg)
    msg = msg or ""
    local lower = string.lower(msg)

    local authorityAdd = string.match(msg, "^[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Tt][Yy]%s+[Aa][Dd][Dd]%s+(.+)$")
    if authorityAdd then
        if not isRaidLeader() then
            print("|cffff0000DetentionRolls:|r Only the Raid Leader can add authority members.")
            return
        end

        local ok = addAuthorityMember(authorityAdd)
        if ok then
            broadcastAuthorityStatus()
            sendSnapshot()
            print("|cff00ff00DetentionRolls:|r Added authority member: " .. authorityAdd)
        else
            print("|cffff0000DetentionRolls:|r Could not add authority member. Use name-server.")
        end
        return
    end

    local authorityRemove = string.match(msg, "^[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Tt][Yy]%s+[Rr][Ee][Mm][Oo][Vv][Ee]%s+(.+)$")
    if authorityRemove then
        if not isRaidLeader() then
            print("|cffff0000DetentionRolls:|r Only the Raid Leader can remove authority members.")
            return
        end

        local ok = removeAuthorityMember(authorityRemove)
        if ok then
            broadcastAuthorityStatus()
            sendSnapshot()
            print("|cff00ff00DetentionRolls:|r Removed authority member: " .. authorityRemove)
        else
            print("|cffff0000DetentionRolls:|r Authority member not found: " .. authorityRemove)
        end
        return
    end

    if lower == "authority list" or lower == "authority" then
        print("|cff00ff00DetentionRolls authority chain:|r")
        for key, fullName in pairs(saved.authorityChain or {}) do
            print(string.format("- %s (%s)", fullName, key))
        end
        return
    end

    if lower == "" then
        toggleSettingsWindow()
        return
    end

    if lower == "reset" then
        PersistenceAdapter:ResetCurrentProfile()

        saved.framePos = { point = "CENTER", x = 0, y = 0 }
        saved.toggleButtonPos = nil
        saved.pinnedToMinimap = false
        saved.hideInCombat = saved.minimizeInCombat
        SessionGate:StopBossSession()

        restoreFramePosition()
        updateFrameVisuals()
        updateToggleButtonPosition()
        updateToggleButtonVisuals()

        if contextMenu and contextMenu:IsShown() then
            contextMenu:Hide()
        end

        print("|cff00ff00DetentionRolls:|r Settings and positions have been reset.")
        return
    end

    if lower == "weights" then
        printWeightSummary()
        return
    end

    if lower == "resetweights" then
        saved.weights = {}
        saved.wins = {}
        for key in pairs(saved.roster or {}) do
            saved.weights[key] = 1.0
            saved.wins[key] = 0
        end
        addRecentEvent("admin", "Weights reset by command", nil)
        refreshTransparencyPanel()
        if isRaidLeader() then
            sendSnapshot()
        end
        print("|cff00ff00DetentionRolls:|r Weights reset.")
        return
    end

    if lower == "sync" then
        if isRaidLeader() then
            sendSnapshot()
            print("|cff00ff00DetentionRolls:|r Full state sent to raid.")
        else
            requestSnapshot()
            print("|cff00ff00DetentionRolls:|r State request sent to Raid Leader.")
        end
        return
    end

    if lower == "activate" then
        if not isRaidLeader() then
            print("|cffff0000DetentionRolls:|r Only Raid Leader can activate raid mode.")
            return
        end
        saved.raidActive = true
        setBossLootSession(true, nil, 240)
        sendComm("RAID_ACTIVATE", "1", GetUnitName("player", true) or "leader")
        sendSnapshot()
        if leaderboardFrame then
            leaderboardFrame:SetShown(canAutoOpenLootWindows() and saved.showCompactLeaderboard)
        end
        print("|cff00ff00DetentionRolls:|r Raid mode activated.")
        return
    end

    if lower == "deactivate" then
        if not isRaidLeader() then
            print("|cffff0000DetentionRolls:|r Only Raid Leader can deactivate raid mode.")
            return
        end
        saved.raidActive = false
        setBossLootSession(false)
        sendComm("RAID_ACTIVATE", "0", GetUnitName("player", true) or "leader")
        if leaderboardFrame then leaderboardFrame:Hide() end
        applyAutoCloseIfComplete()
        print("|cffffff00DetentionRolls:|r Raid mode deactivated.")
        return
    end

    if lower == "testloot" then
        if not saved.testMode then
            print("|cffffaa00DetentionRolls:|r Enable fake loot test mode in settings first.")
            return
        end
        addFakeLootToSession()
        print("|cff00ff00DetentionRolls:|r Added fake loot item to current session.")
        return
    end

    if lower == "testenv" then
        if not saved.testMode then
            print("|cffffaa00DetentionRolls:|r Enable fake loot test mode in settings first.")
            return
        end
        local count = tonumber(saved.testLootCount) or 6
        for _ = 1, count do
            addFakeLootToSession()
        end
        print(string.format("|cff00ff00DetentionRolls:|r Generated test environment with %d fake items.", count))
        return
    end

    local addPayload = string.match(msg, "^[Aa][Dd][Dd]%s+(.+)$")
    if addPayload then
        if not isRaidLeader() then
            print("|cffff0000DetentionRolls:|r Only Raid Leader can add session loot manually.")
            return
        end
        local ok, info = addManualSessionItem(addPayload)
        if ok then
            print("|cff00ff00DetentionRolls:|r " .. info)
        else
            print("|cffff0000DetentionRolls:|r " .. (info or "Could not add item."))
        end
        return
    end

    local winTarget = string.match(msg, "^win%s+(.+)$")
    if winTarget then
        if not isRaidLeader() then
            print("|cffff0000DetentionRolls:|r Only Raid Leader can apply win penalties.")
            return
        end

        local key = tryResolvePlayerKey(winTarget)
        if not key then
            print("|cffff0000DetentionRolls:|r Could not find player in imported roster: " .. winTarget)
            return
        end

        applyWinPenalty(key, GetUnitName("player", true))
        local newMultiplier = getPlayerMultiplier(key)
        sendComm("WIN_APPLY", key, tostring(newMultiplier), tostring(saved.wins[key] or 0), GetUnitName("player", true) or "leader")
        sendSnapshot()

        print(string.format("|cff00ff00DetentionRolls:|r Applied penalty to %s. New multiplier: %.2f", winTarget, newMultiplier))
        refreshTransparencyPanel()
        return
    end

    local setPenaltyTarget, setPenaltyValue = string.match(msg, "^[Ss][Ee][Tt][Pp][Ee][Nn][Aa][Ll][Tt][Yy]%s+([^%s]+)%s+([%d%.]+)$")
    if setPenaltyTarget and setPenaltyValue then
        if not isRaidLeader() then
            print("|cffff0000DetentionRolls:|r Only Raid Leader can set penalties.")
            return
        end

        local key = tryResolvePlayerKey(setPenaltyTarget)
        if not key then
            print("|cffff0000DetentionRolls:|r Could not find player in imported roster: " .. setPenaltyTarget)
            return
        end

        local multiplier = tonumber(setPenaltyValue)
        if not multiplier then
            print("|cffff0000DetentionRolls:|r Invalid multiplier. Example: /dr setpenalty Valac 0.85")
            return
        end

        setPlayerMultiplier(key, multiplier)
        local newMultiplier = getPlayerMultiplier(key)
        local displayName = (saved.roster[key] and saved.roster[key].displayName) or setPenaltyTarget
        addRecentEvent("admin", string.format("Manual penalty set for %s: %.2f", displayName, newMultiplier), nil)
        sendComm("WIN_APPLY", key, tostring(newMultiplier), tostring(saved.wins[key] or 0), GetUnitName("player", true) or "leader")
        sendSnapshot()
        refreshTransparencyPanel()

        print(string.format("|cff00ff00DetentionRolls:|r Set %s multiplier to %.2f", displayName, newMultiplier))
        return
    end

    local setWinsTarget, setWinsValue = string.match(msg, "^[Ss][Ee][Tt][Ww][Ii][Nn][Ss]%s+([^%s]+)%s+(%d+)$")
    if setWinsTarget and setWinsValue then
        if not isRaidLeader() then
            print("|cffff0000DetentionRolls:|r Only Raid Leader can set wins.")
            return
        end

        local key = tryResolvePlayerKey(setWinsTarget)
        if not key then
            print("|cffff0000DetentionRolls:|r Could not find player in imported roster: " .. setWinsTarget)
            return
        end

        saved.wins[key] = tonumber(setWinsValue) or 0
        local displayName = (saved.roster[key] and saved.roster[key].displayName) or setWinsTarget
        addRecentEvent("admin", string.format("Manual wins set for %s: %d", displayName, saved.wins[key]), nil)
        sendComm("WIN_APPLY", key, tostring(getPlayerMultiplier(key)), tostring(saved.wins[key]), GetUnitName("player", true) or "leader")
        sendSnapshot()
        refreshTransparencyPanel()

        print(string.format("|cff00ff00DetentionRolls:|r Set %s wins to %d", displayName, saved.wins[key]))
        return
    end

    print("|cff00ff00DetentionRolls commands:|r")
    print("/detentionrolls - Open settings")
    print("/detentionrolls reset - Reset settings and positions")
    print("/detentionrolls weights - Show current penalties")
    print("/detentionrolls resetweights - Reset all penalties")
    print("/detentionrolls win <player> - Raid Leader applies win penalty")
    print("/detentionrolls setpenalty <player> <multiplier> - Set multiplier manually")
    print("/detentionrolls setwins <player> <wins> - Set wins/items manually")
    print("/detentionrolls sync - Leader push or member request state")
    print("/dr add <item> - Raid Leader manually adds item to session")
    print("/dr activate | /dr deactivate - Toggle raid mode authority")
    print("/dr testloot - Add one fake item in test mode")
    print("/dr testenv - Generate fake loot session batch")
end

local function handleRollSystemMessage(message)
    local roller, rolled, minRoll, maxRoll = string.match(message or "", "^(.+) rolls (%d+) %((%d+)%-(%d+)%)$")
    if not roller then
        return
    end

    local myName = GetUnitName("player", true)
    if not pendingLocalRoll then
        return
    end

    local pendingSimple = string.match(pendingLocalRoll.fullName or "", "^([^%-]+)") or pendingLocalRoll.fullName
    local rollerSimple = string.match(roller, "^([^%-]+)") or roller

    if rollerSimple ~= pendingSimple then
        return
    end

    local r = tonumber(rolled)
    local minV = tonumber(minRoll)
    local maxV = tonumber(maxRoll)

    if not r or not minV or not maxV then
        return
    end

    if minV ~= 0 then
        return
    end

    if maxV ~= pendingLocalRoll.max then
        return
    end

    local itemSuffix = pendingLocalRoll.itemName and (" for " .. pendingLocalRoll.itemName) or ""
    local eventText = string.format("%s rolled %d on %s%s (0-%d, x%.2f)", pendingLocalRoll.fullName or roller, r, pendingLocalRoll.label, itemSuffix, pendingLocalRoll.max, pendingLocalRoll.multiplier)

    if pendingLocalRoll.phase == "rolloff" then
        if pendingLocalRoll.itemId then
            addSessionRollOffRoll(pendingLocalRoll.itemId, pendingLocalRoll.round, pendingLocalRoll.key, pendingLocalRoll.fullName or roller, r, pendingLocalRoll.max)
            local row = getSessionItemById(pendingLocalRoll.itemId)
            if row then
                advanceSessionRowState(row)
            end
            refreshLootSessionWindow()
        end

        if isRaidLeader() then
            addRecentEvent("roll", eventText, {
                player = pendingLocalRoll.fullName,
                category = "rolloff",
                rolled = r,
                max = pendingLocalRoll.max,
                mult = pendingLocalRoll.multiplier,
            })
            sendComm("ROLLOFF_EVENT", pendingLocalRoll.itemId or "", tostring(pendingLocalRoll.round or 0), pendingLocalRoll.key, pendingLocalRoll.fullName or roller, tostring(r), tostring(pendingLocalRoll.max))
        else
            local now = GetTime()
            if now - rollSubmissionThrottle > 0.2 then
                rollSubmissionThrottle = now
                sendComm("ROLLOFF_SUBMIT", pendingLocalRoll.itemId or "", tostring(pendingLocalRoll.round or 0), pendingLocalRoll.key, pendingLocalRoll.fullName or roller, tostring(r), tostring(pendingLocalRoll.max))
            end
        end
    else
        if pendingLocalRoll.itemId then
            addSessionRoll(pendingLocalRoll.itemId, pendingLocalRoll.key, pendingLocalRoll.fullName or roller, pendingLocalRoll.category, pendingLocalRoll.label, r, pendingLocalRoll.max, pendingLocalRoll.multiplier)
            refreshLootSessionWindow()
        end

        if isRaidLeader() then
            addRecentEvent("roll", eventText, {
                player = pendingLocalRoll.fullName,
                category = pendingLocalRoll.category,
                rolled = r,
                max = pendingLocalRoll.max,
                mult = pendingLocalRoll.multiplier,
            })
            sendComm("ROLL_EVENT", pendingLocalRoll.key, pendingLocalRoll.fullName or roller, pendingLocalRoll.category, pendingLocalRoll.label, tostring(r), tostring(pendingLocalRoll.max), string.format("%.3f", pendingLocalRoll.multiplier), pendingLocalRoll.itemId or "", pendingLocalRoll.itemName or "")
        else
            local now = GetTime()
            if now - rollSubmissionThrottle > 0.2 then
                rollSubmissionThrottle = now
                sendComm("ROLL_SUBMIT", pendingLocalRoll.key, pendingLocalRoll.fullName or roller, pendingLocalRoll.category, pendingLocalRoll.label, tostring(r), tostring(pendingLocalRoll.max), string.format("%.3f", pendingLocalRoll.multiplier), pendingLocalRoll.itemId or "", pendingLocalRoll.itemName or "")
            end
        end
    end

    pendingLocalRoll = nil
    refreshTransparencyPanel()
end

local function handleLootChatMessage(message)
    local itemLink = string.match(message or "", "(|c%x+|Hitem:[^|]+|h%[[^%]]+%]|h|r)") or string.match(message or "", "(|Hitem:[^|]+|h%[[^%]]+%]|h)")
    if not itemLink then return end
    local itemName = string.match(itemLink, "|h%[([^%]]+)%]|h") or itemLink

    if canAutoOpenLootWindows() then
        showLootUiWindows()
    end

    local winner = string.match(message or "", "^(.+) won: ")
    if not winner then
        winner = string.match(message or "", "^You receive loot: ") and (GetUnitName("player", true) or "You") or nil
    end

    if winner and isRaidLeader() and saved and saved.raidActive and isInRaid() and isBossLootSessionOpen() then
        local selfKey = select(1, getSelfKey())
        local winnerKey = normalizeFullNameToKey(winner)
        if winnerKey ~= "" and winnerKey == selfKey then
            local row = findPendingSessionItemByLoot(itemLink, itemName)
            if not row then
                syncCurrentRaidMembersToSaved()
                local eligibleCount = getEligibleSessionRollerCount()
                row = addSessionItem(itemName, itemLink, "loot", nil, time(), time() + 120, eligibleCount)
                sendComm("SESSION_ADD", row.id, itemName, itemLink or "", tostring(row.ts), tostring(row.expiresAt), tostring(row.eligibleCount), row.source or "loot")
            end
            if canAutoOpenLootWindows() then
                showLootUiWindows()
            end
            refreshLootSessionWindow()
            return
        end
    end

    if winner then
        updateSessionWinner(itemLink, winner)
        if isRaidLeader() and saved.raidActive then
            sendComm("SESSION_WIN", itemLink, winner)
        end
        refreshLootSessionWindow()
    end
end

local function handleAddonMessage(prefix, message, channel, sender)
    if prefix ~= COMM_PREFIX then
        return
    end

    local parts = splitByDelim(message)
    local messageType = parts[1]
    if not messageType then return end

    local senderIsLeader = isSenderGroupLeader(sender)
    local senderKey = normalizeFullNameToKey(sender)
    local senderIsAuthority = isAuthorityKey(senderKey)

    if messageType == "AUTH_STATUS" then
        local authorityKey = parts[2]
        local authorityFullName = parts[3]
        local authorityTimestamp = tonumber(parts[4]) or 0
        local authorityUpdatedAt = parts[5] or ""

        if senderIsLeader and authorityKey and authorityKey ~= "" then
            saved.authorityChain = saved.authorityChain or {}
            saved.authorityChain[authorityKey] = authorityFullName
            senderIsAuthority = true
        end

        if senderIsLeader or isAuthorityKey(authorityKey or "") or senderIsAuthority then
            authorityStatusInbox[authorityKey or senderKey] = {
                key = authorityKey or senderKey,
                fullName = authorityFullName or sender,
                timestamp = authorityTimestamp,
                updatedAt = authorityUpdatedAt,
                verifiedAt = time(),
            }
        end
        return
    end

    if messageType == "HELLO" then
        broadcastAuthorityStatus()
        scheduleAuthoritySnapshotEvaluation()
        return
    end

    if messageType == "STATE_REQUEST" then
        broadcastAuthorityStatus()
        scheduleAuthoritySnapshotEvaluation()
        return
    end

    if messageType == "STATE_BEGIN" then
        if not isFreshAuthoritySender(sender, senderIsLeader, senderIsAuthority) then
            addRecentEvent("sync", string.format("Rejected stale snapshot start from %s", sender or "unknown"), nil)
            return
        end
        local seq = tonumber(parts[2])
        if not seq then return end
        snapshotInbox[sender] = snapshotInbox[sender] or {}
        snapshotInbox[sender][seq] = {
            total = tonumber(parts[3]) or 0,
            chunks = {},
        }
        return
    end

    if messageType == "STATE_CHUNK" then
        if not isFreshAuthoritySender(sender, senderIsLeader, senderIsAuthority) then return end
        local seq = tonumber(parts[2])
        local idx = tonumber(parts[3])
        if not seq or not idx then return end

        local senderBox = snapshotInbox[sender]
        if not senderBox or not senderBox[seq] then return end
        senderBox[seq].chunks[idx] = parts[5] or ""
        return
    end

    if messageType == "STATE_END" then
        if not isFreshAuthoritySender(sender, senderIsLeader, senderIsAuthority) then
            addRecentEvent("sync", string.format("Rejected stale snapshot end from %s", sender or "unknown"), nil)
            return
        end
        local seq = tonumber(parts[2])
        if not seq then return end

        local senderBox = snapshotInbox[sender]
        if not senderBox or not senderBox[seq] then return end

        local box = senderBox[seq]
        local payloadParts = {}
        for i = 1, box.total do
            payloadParts[#payloadParts + 1] = box.chunks[i] or ""
        end

        local payload = table.concat(payloadParts)
        local ok = applySnapshotPayload(payload)
        if ok then
            print("|cff00ff00DetentionRolls:|r Synced state from Raid Leader.")
        end

        senderBox[seq] = nil
        refreshTransparencyPanel()
        return
    end

    if messageType == "ROLL_SUBMIT" then
        if not isRaidLeader() then return end

        local playerKey = parts[2]
        local fullName = parts[3]
        local category = parts[4]
        local label = parts[5]
        local rolled = tonumber(parts[6]) or 0
        local maxV = tonumber(parts[7]) or 0
        local mult = tonumber(parts[8]) or 1.0
        local itemId = parts[9]
        local itemName = parts[10]

        if itemId and itemId ~= "" then
            addSessionRoll(itemId, playerKey, fullName, category, label, rolled, maxV, mult)
        end

        local itemSuffix = (itemName and itemName ~= "") and (" for " .. itemName) or ""
        local text = string.format("%s rolled %d on %s%s (0-%d, x%.2f)", fullName or playerKey, rolled, label or category, itemSuffix, maxV, mult)
        addRecentEvent("roll", text, nil)

        sendComm("ROLL_EVENT", playerKey, fullName, category, label, tostring(rolled), tostring(maxV), string.format("%.3f", mult), itemId or "", itemName or "")
        refreshLeaderboardPopup()
        refreshLootSessionWindow()
        refreshTransparencyPanel()
        return
    end

    if messageType == "ROLLOFF_START" then
        if not senderIsLeader then return end

        local itemId = parts[2]
        local round = tonumber(parts[3]) or 0
        local expiresAt = tonumber(parts[4]) or (time() + ROLLOFF_DURATION_SECONDS)
        local participants = deserializeKeyList(parts[5])
        local row = itemId and getSessionItemById(itemId) or nil
        if row then
            startSessionRollOff(row, participants, expiresAt, round, true)
            refreshLootSessionWindow()
        end
        return
    end

    if messageType == "ROLLOFF_SUBMIT" then
        if not isRaidLeader() then return end

        local itemId = parts[2]
        local round = tonumber(parts[3]) or 0
        local playerKey = parts[4]
        local fullName = parts[5]
        local rolled = tonumber(parts[6]) or 0
        local maxV = tonumber(parts[7]) or 100

        local ok, row = addSessionRollOffRoll(itemId, round, playerKey, fullName, rolled, maxV)
        if ok and row then
            advanceSessionRowState(row)
            addRecentEvent("roll", string.format("%s rolled %d on Roll-Off for %s (0-%d, x1.00)", fullName or playerKey, rolled, row.itemName or "selected loot", maxV), nil)
            sendComm("ROLLOFF_EVENT", itemId or "", tostring(round), playerKey, fullName, tostring(rolled), tostring(maxV))
            refreshLeaderboardPopup()
            refreshLootSessionWindow()
            refreshTransparencyPanel()
        end
        return
    end

    if messageType == "ROLLOFF_EVENT" then
        local itemId = parts[2]
        local round = tonumber(parts[3]) or 0
        local playerKey = parts[4]
        local fullName = parts[5]
        local rolled = tonumber(parts[6]) or 0
        local maxV = tonumber(parts[7]) or 100

        local ok, row = addSessionRollOffRoll(itemId, round, playerKey, fullName, rolled, maxV)
        if ok and row then
            if senderIsLeader then
                advanceSessionRowState(row)
            end
            addRecentEvent("roll", string.format("%s rolled %d on Roll-Off for %s (0-%d, x1.00)", fullName or playerKey, rolled, row.itemName or "selected loot", maxV), nil)
            refreshLeaderboardPopup()
            refreshLootSessionWindow()
            refreshTransparencyPanel()
        end
        return
    end

    if messageType == "ROLLOFF_RESOLVE" then
        if not senderIsLeader then return end

        local itemId = parts[2]
        local round = tonumber(parts[3]) or 0
        local winnerKey = parts[4]
        local winnerName = parts[5]
        local winnerRoll = tonumber(parts[6]) or 0
        local row = itemId and getSessionItemById(itemId) or nil
        if row then
            row.rolloffActive = false
            row.rolloffRound = round
            row.rolloffExpiresAt = 0
            row.rolloffWinnerKey = winnerKey ~= "" and winnerKey or nil
            row.rolloffWinnerName = winnerName ~= "" and winnerName or nil
            row.rolloffWinnerRoll = winnerRoll
            row.rolloffParticipants = {}
            row.tiePlayerKeys = {}
            row.state = "closed"
            row.lastDecision = row.rolloffWinnerName and ("Roll-off won by " .. row.rolloffWinnerName) or row.lastDecision
            updateSessionRollSummary(row)
            refreshLootSessionWindow()
        end
        return
    end

    if messageType == "ROLL_EVENT" then
        local playerKey = parts[2]
        local fullName = parts[3]
        local category = parts[4]
        local label = parts[5]
        local rolled = tonumber(parts[6]) or 0
        local maxV = tonumber(parts[7]) or 0
        local mult = tonumber(parts[8]) or 1.0
        local itemId = parts[9]
        local itemName = parts[10]

        if itemId and itemId ~= "" then
            addSessionRoll(itemId, playerKey, fullName, category, label, rolled, maxV, mult)
            refreshLootSessionWindow()
        end

        local itemSuffix = (itemName and itemName ~= "") and (" for " .. itemName) or ""
        local text = string.format("%s rolled %d on %s%s (0-%d, x%.2f)", fullName or playerKey, rolled, label or category, itemSuffix, maxV, mult)
        addRecentEvent("roll", text, nil)
        refreshLeaderboardPopup()
        refreshTransparencyPanel()
        return
    end

    if messageType == "SESSION_ADD" then
        if not senderIsLeader then return end
        local itemId = parts[2]
        local itemName = parts[3]
        local itemLink = parts[4]
        local createdAt = tonumber(parts[5]) or time()
        local expiresAt = tonumber(parts[6]) or (createdAt + 120)
        local eligibleCount = tonumber(parts[7]) or 1
        local source = parts[8]
        if source == "test" then
            createdAt = time()
            expiresAt = createdAt + 120
        end
        if not getSessionItemById(itemId) then
            addSessionItem(itemName, itemLink ~= "" and itemLink or nil, source ~= "" and source or "manual", itemId, createdAt, expiresAt, eligibleCount)
        end
        if source == "loot" and senderIsLeader and isInRaid() then
            setBossLootSession(true, nil, 240)
        end
        if canAutoOpenLootWindows() then
            showLootUiWindows()
        end
        refreshLootSessionWindow()
        applyAutoCloseIfComplete()
        return
    end

    if messageType == "SESSION_WIN" then
        if not senderIsLeader then return end
        local itemLink = parts[2]
        local winner = parts[3]
        updateSessionWinner(itemLink, winner)
        refreshLootSessionWindow()
        return
    end

    if messageType == "SESSION_DECIDE" then
        if not senderIsLeader then return end
        local itemId = parts[2]
        local action = parts[3]

        if action == "award" then
            awardSessionItem(itemId, sender, false)
        elseif action == "deny" then
            denySessionItemTopRoll(itemId)
        end

        refreshLootSessionWindow()
        return
    end

    if messageType == "SESSION_CLEAR" then
        if not senderIsLeader then return end
        saved.lootSession = {}
        selectedSessionItemId = nil
        setBossLootSession(false)
        refreshLootSessionWindow()
        applyAutoCloseIfComplete()
        return
    end

    if messageType == "RAID_ACTIVATE" then
        if not senderIsLeader then return end
        local enabled = parts[2] == "1"
        saved.raidActive = enabled
        if not enabled then
            setBossLootSession(false)
        end
        addRecentEvent("admin", string.format("Raid mode %s by %s", enabled and "activated" or "deactivated", parts[3] or sender), nil)
        if leaderboardFrame then
            leaderboardFrame:SetShown(canAutoOpenLootWindows() and saved.showCompactLeaderboard)
            refreshLeaderboardPopup()
        end
        applyAutoCloseIfComplete()
        return
    end

    if messageType == "WIN_APPLY" then
        if not senderIsLeader then return end

        local playerKey = parts[2]
        local newMultiplier = tonumber(parts[3]) or 1.0
        local wins = tonumber(parts[4]) or 0
        local actor = parts[5]

        setPlayerMultiplier(playerKey, newMultiplier)
        saved.wins[playerKey] = wins

        local displayName = (saved.roster[playerKey] and saved.roster[playerKey].displayName) or playerKey
        addRecentEvent("win", string.format("Penalty updated for %s by %s (multiplier %.2f)", displayName, actor or "Raid Leader", newMultiplier), nil)
        refreshLeaderboardPopup()
        refreshTransparencyPanel()
        return
    end

    if messageType == "BOSS_RECOVERY" then
        if not senderIsLeader then return end
        local bossName = parts[2]
        local bossId = tonumber(parts[3])
        local recovery = tonumber(parts[4])

        if recovery and recovery >= 0 then
            saved.recoveryPerBoss = recovery
        end

        applyBossRecovery(bossName, bossId)
        refreshTransparencyPanel()
        return
    end
end

local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("ADDON_LOADED")
eventFrame:RegisterEvent("PLAYER_LOGIN")
eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
eventFrame:RegisterEvent("PLAYER_REGEN_DISABLED")
eventFrame:RegisterEvent("PLAYER_REGEN_ENABLED")
eventFrame:RegisterEvent("CHAT_MSG_SYSTEM")
eventFrame:RegisterEvent("CHAT_MSG_LOOT")
eventFrame:RegisterEvent("CHAT_MSG_ADDON")
eventFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
eventFrame:RegisterEvent("ENCOUNTER_END")
eventFrame:RegisterEvent("ENCOUNTER_LOOT_RECEIVED")
eventFrame:RegisterEvent("START_LOOT_ROLL")
eventFrame:RegisterEvent("TRADE_SHOW")
eventFrame:RegisterEvent("TRADE_CLOSED")

eventFrame:SetScript("OnEvent", function(_, event, arg1, arg2, arg3, arg4)
    if event == "ADDON_LOADED" and arg1 == addonName then
        PersistenceAdapter:Initialize()
        invalidateRaidRosterCache()

        createMainFrame()
        createToggleButton()
        createCompactLeaderboardPopup()
        createLootSessionWindow()

        if not authorityStatusTicker then
            authorityStatusTicker = C_Timer.NewTicker(AUTHORITY_STATUS_INTERVAL_SECONDS, function()
                if not saved then
                    return
                end

                local authorityUiVisible = (contextMenu and contextMenu:IsShown()) or (transparencyPanel and transparencyPanel:IsShown())
                if not isInRaid() and not authorityUiVisible then
                    return
                end

                if shouldBroadcastAuthorityState() then
                    broadcastAuthorityStatus()
                end
                if contextMenu and contextMenu:IsShown() and contextMenu.RefreshAuthorityWidgets then
                    contextMenu:RefreshAuthorityWidgets()
                end
                if transparencyPanel and transparencyPanel:IsShown() then
                    refreshTransparencyPanel()
                end
            end)
        end

        if saved.alwaysShowButtons then
            frame:Show()
        else
            frame:Hide()
        end

        if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then
            C_ChatInfo.RegisterAddonMessagePrefix(COMM_PREFIX)
        end

        if not isInRaid() then
            saved.raidActivationPrompted = false
        end

    elseif event == "PLAYER_LOGIN" then
        print("|cff00ff00DetentionRolls:|r Loaded. Use |cffffff00/detentionrolls|r for options.")

        if isInRaid() then
            broadcastAuthorityStatus()
            sendComm("HELLO", GetUnitName("player", true) or "unknown")
        end

    elseif event == "PLAYER_ENTERING_WORLD" then
        -- Prevent stale windows from popping due to zoning/reload roster churn.
        SessionGate:StopBossSession()
        UiAutomation:ApplyAutoClose()

    elseif event == "PLAYER_REGEN_DISABLED" then
        UiAutomation:OnEnterCombat()
        if contextMenu and contextMenu:IsShown() then
            contextMenu:Hide()
        end
        if importFrame and importFrame:IsShown() then
            importFrame:Hide()
        end

    elseif event == "PLAYER_REGEN_ENABLED" then
        UiAutomation:OnLeaveCombat()

    elseif event == "CHAT_MSG_SYSTEM" then
        handleRollSystemMessage(arg1)

    elseif event == "CHAT_MSG_LOOT" then
        handleLootChatMessage(arg1)

    elseif event == "CHAT_MSG_ADDON" then
        handleAddonMessage(arg1, arg2, arg3, arg4)

    elseif event == "GROUP_ROSTER_UPDATE" then
        invalidateRaidRosterCache()
        if isInRaid() then
            syncCurrentRaidMembersToSaved()
            broadcastAuthorityStatus()
            sendComm("HELLO", GetUnitName("player", true) or "unknown")
            if leaderboardFrame then
                leaderboardFrame:SetShown(SessionGate:CanAutoOpen() and saved.showCompactLeaderboard)
                refreshLeaderboardPopup()
            end
        else
            saved.raidActivationPrompted = false
            SessionGate:StopBossSession()
            if leaderboardFrame then
                leaderboardFrame:Hide()
            end
        end

    elseif event == "START_LOOT_ROLL" then
        onStartLootRoll(arg1, arg2)

    elseif event == "ENCOUNTER_LOOT_RECEIVED" then
        if isInRaid() then
            SessionGate:StartBossSession(arg1, 240)
            if isRaidLeader() and not saved.raidActive then
                showRaidActivationPrompt()
            end
        end

    elseif event == "TRADE_SHOW" then
        TradeAutomation:TryAutoTrade()

    elseif event == "TRADE_CLOSED" then
        -- Keep session state; just ensure no unintended auto-open remains armed.
        UiAutomation:ApplyAutoClose()

    elseif event == "ENCOUNTER_END" then
        local encounterId = tonumber(arg1)
        local encounterName = tostring(arg2 or "Unknown Encounter")
        local success = tonumber(arg3)

        if success == 1 and isRaidLeader() then
            if applyBossRecovery(encounterName, encounterId) then
                sendComm("BOSS_RECOVERY", encounterName, tostring(encounterId or 0), string.format("%.3f", saved.recoveryPerBoss or 0.05))
                sendSnapshot()
            end
            refreshTransparencyPanel()
        end
    end
end)
