local ADDON_NAME, RPAL = ...

-- ============================================================
-- RPAL DM KIT
-- Campaign preparation is persistent; live sessions are intentionally
-- lightweight and network-authoritative on the host.
-- ============================================================

local DM = RPAL.DMKit or {}
RPAL.DMKit = DM

local PREFIX = "RPAL_DM"
local PROTOCOL = 2
local MAX_PARTICIPANTS = 20
local MAX_LOG = 220
local MAX_NOTIFICATIONS = 30
local MAX_REQUESTS = 50
local VALID_DICE = {[4]=true,[6]=true,[8]=true,[12]=true,[20]=true,[100]=true}
local ROLL_STATS = {STR="Strength",DEX="Dexterity",CON="Constitution",INT="Intelligence",WIS="Wisdom",CHA="Charisma"}
local ROLL_STAT_ORDER = {"STR","DEX","CON","INT","WIS","CHA"}


DM.PREFIX = PREFIX
DM.PROTOCOL = PROTOCOL
DM.state = DM.state or nil
DM._incomingInvite = DM._incomingInvite or nil
DM._serial = DM._serial or 0
DM._selectedFolder = DM._selectedFolder or nil
DM._selectedNPCs = DM._selectedNPCs or {}

local function CopyTable(value,seen)
    if type(value)~="table" then return value end
    seen=seen or {};if seen[value] then return seen[value] end
    local out={};seen[value]=out
    for k,v in pairs(value) do out[CopyTable(k,seen)]=CopyTable(v,seen) end
    return out
end

local function Trim(v)
    return tostring(v or ""):gsub("^%s+",""):gsub("%s+$","")
end

local function FullName(unit)
    local name,realm=UnitName(unit)
    if not name then return nil end
    realm=realm or GetRealmName() or ""
    realm=tostring(realm):gsub("%s+","")
    return realm~="" and (name.."-"..realm) or name
end

local function ShortName(name)
    return tostring(name or ""):gsub("%-.*$","")
end

local function NormalizeName(name)
    local value=Trim(name):gsub("%s+","")
    if value=="" then return "" end
    if not value:find("-",1,true) then
        local realm=tostring(GetRealmName and GetRealmName() or ""):gsub("%s+","")
        if realm~="" then value=value.."-"..realm end
    end
    return value:lower()
end

local function SamePlayer(a,b)
    local x=NormalizeName(a)
    return x~="" and x==NormalizeName(b)
end

local function LocalKey()
    return FullName("player") or UnitName("player") or "player"
end

local function LocalTRPName()
    if RPAL.DisplayName then
        local ok,value=pcall(RPAL.DisplayName)
        if ok and value and value~="" then return tostring(value):sub(1,60) end
    end
    return tostring(UnitName("player") or "Player"):sub(1,60)
end

local function NormalizeRollStat(stat)
    stat=tostring(stat or ""):upper()
    return ROLL_STATS[stat] and stat or nil
end

local function LocalRollStatModifier(stat)
    stat=NormalizeRollStat(stat)
    if not stat then return 0 end
    if RPAL.GetCharacterSheetModifier then
        local ok,value=pcall(RPAL.GetCharacterSheetModifier,stat)
        if ok then return math.max(-20,math.min(20,math.floor(tonumber(value) or 0))) end
    end
    return 0
end

function DM.GetRollStatOptions()
    local out={{id="",label="No stat modifier"}}
    for _,id in ipairs(ROLL_STAT_ORDER) do out[#out+1]={id=id,label=ROLL_STATS[id].." ("..id..")"} end
    return out
end

function DM.GetRollStatLabel(stat)
    stat=NormalizeRollStat(stat)
    return stat and ROLL_STATS[stat] or nil
end

local function LocalDMStyle()
    local appearance=RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection() or {}
    local theme=RPAL.GetCharacterThemeSelection and RPAL.GetCharacterThemeSelection() or {}
    local themeName=theme.themeManualOverride and (theme.themeName or "Obsidian Crown") or "Class Specific"
    -- Use the palette RPAL is actually displaying right now. Class color alone
    -- was wrong for manual themes and made the local host card look like a
    -- default participant until the session was recreated.
    local red,green,blue=.92,.76,.35
    if RPAL.GetCurrentAccentColor then
        local r,g,b=RPAL.GetCurrentAccentColor();red,green,blue=r or red,g or green,b or blue
    elseif RPAL.GetCurrentThemePalette then
        local pal=RPAL.GetCurrentThemePalette();local g=pal and pal.gold;if g then red,green,blue=g[1] or red,g[2] or green,g[3] or blue end
    end
    local hex=string.format("%02X%02X%02X",math.floor(red*255+.5),math.floor(green*255+.5),math.floor(blue*255+.5))
    local level=RPAL.GetCurrentRenownLevel and RPAL.GetCurrentRenownLevel() or 1
    return {
        theme=tostring(themeName):sub(1,24),
        themeHex=hex,
        portraitFrame=tostring(appearance.portraitFrame or "Default"):sub(1,24),
        levelBadge=tostring(appearance.levelBadge or "Initiate Seal"):sub(1,24),
        level=tonumber(level) or 1,
    }
end

local function ApplyLocalStyle(participant)
    if not participant then return end
    local style=LocalDMStyle()
    participant.theme=style.theme;participant.themeHex=style.themeHex
    participant.portraitFrame=style.portraitFrame;participant.levelBadge=style.levelBadge;participant.level=style.level
end

local function RemoteTRPName(name)
    if RPAL.GetOtherTRPCharacterName then
        local ok,value=pcall(RPAL.GetOtherTRPCharacterName,name)
        if ok and value and value~="" then return tostring(value):sub(1,60) end
    end
    return ShortName(name):sub(1,60)
end

local function NewID(prefix)
    if RPAL.NewID then
        local ok,value=pcall(RPAL.NewID,prefix or "dm")
        if ok and value then return value end
    end
    DM._serial=DM._serial+1
    return tostring(prefix or "dm").."-"..tostring(time()).."-"..tostring(DM._serial).."-"..tostring(math.random(1000,9999))
end

local function ClampInt(value,lo,hi)
    value=tonumber(value)
    if not value or value~=value or value==math.huge or value==-math.huge then return nil end
    value=math.floor(value+0.00001)
    if value<(lo or -2147483648) or value>(hi or 2147483647) then return nil end
    return value
end

local function Encode(value)
    value=tostring(value or "")
    value=value:gsub("%%","%%25"):gsub("|","%%7C"):gsub("\r","%%0D"):gsub("\n","%%0A")
    return value
end

local function Decode(value)
    value=tostring(value or "")
    value=value:gsub("%%0A","\n"):gsub("%%0D","\r"):gsub("%%7C","|"):gsub("%%25","%%")
    return value
end

local function Send(target,kind,...)
    if not target or target=="" or not C_ChatInfo or not C_ChatInfo.SendAddonMessage then return false end
    local fields={tostring(PROTOCOL),tostring(kind or "")}
    for i=1,select("#",...) do fields[#fields+1]=Encode(select(i,...)) end
    local message=table.concat(fields,"|")
    if #message>255 then
        return false
    end
    if RPAL.SendAddonMessageSafe then return RPAL.SendAddonMessageSafe(PREFIX,message,"WHISPER",target,true) end
    local ok,result=pcall(C_ChatInfo.SendAddonMessage,PREFIX,message,"WHISPER",target)
    return ok and (result==nil or result==true or tonumber(result)==0)
end

local function Data()
    RPAL_DB=RPAL_DB or {}
    RPAL_DB.dmKit=RPAL_DB.dmKit or {}
    local db=RPAL_DB.dmKit
    db.campaigns=db.campaigns or {}
    db.campaignOrder=db.campaignOrder or {}
    db.notesWindow=db.notesWindow or {x=0,y=0,w=420,h=300}
    return db
end

local function EnsureCampaignSchema(c)
    if type(c)~="table" then return c end
    c.folders=type(c.folders)=="table" and c.folders or {}
    c.folderOrder=type(c.folderOrder)=="table" and c.folderOrder or {}
    c.npcs=type(c.npcs)=="table" and c.npcs or {}
    c.npcOrder=type(c.npcOrder)=="table" and c.npcOrder or {}
    c.sessionHistory=type(c.sessionHistory)=="table" and c.sessionHistory or {}
    c.customStatuses=type(c.customStatuses)=="table" and c.customStatuses or {}
    c.encounterTemplates=type(c.encounterTemplates)=="table" and c.encounterTemplates or {}
    c.encounterTemplateOrder=type(c.encounterTemplateOrder)=="table" and c.encounterTemplateOrder or {}
    -- V2.3 file-manager assets. Encounter Templates remain as a storage alias
    -- for backward compatibility, but the UI calls them Encounter Presets.
    c.noteFiles=type(c.noteFiles)=="table" and c.noteFiles or {}
    c.noteOrder=type(c.noteOrder)=="table" and c.noteOrder or {}
    c.notificationPresets=type(c.notificationPresets)=="table" and c.notificationPresets or {}
    c.notificationPresetOrder=type(c.notificationPresetOrder)=="table" and c.notificationPresetOrder or {}
    c.rollPresets=type(c.rollPresets)=="table" and c.rollPresets or {}
    c.rollPresetOrder=type(c.rollPresetOrder)=="table" and c.rollPresetOrder or {}
    c.screenPresets=type(c.screenPresets)=="table" and c.screenPresets or {}
    c.screenPresetOrder=type(c.screenPresetOrder)=="table" and c.screenPresetOrder or {}
    c.campaignStats=type(c.campaignStats)=="table" and c.campaignStats or {}
    c.campaignStats.participants=type(c.campaignStats.participants)=="table" and c.campaignStats.participants or {}
    c.campaignStats.npcsInteracted=type(c.campaignStats.npcsInteracted)=="table" and c.campaignStats.npcsInteracted or {}
    c.campaignStats.npcOutcomes=type(c.campaignStats.npcOutcomes)=="table" and c.campaignStats.npcOutcomes or {}
    c.campaignStats.rollMin=tonumber(c.campaignStats.rollMin)
    c.campaignStats.rollMax=tonumber(c.campaignStats.rollMax)
    c.campaignStats.rollCount=math.max(0,math.floor(tonumber(c.campaignStats.rollCount) or 0))
    if c.campaignStats.sessionCount==nil then c.campaignStats.sessionCount=#c.sessionHistory end
    c.campaignStats.sessionCount=math.max(0,math.floor(tonumber(c.campaignStats.sessionCount) or 0))
    if c.campaignStats.activeDuration==nil then
        local duration=0;for _,segment in ipairs(c.sessionHistory) do duration=duration+math.max(0,(tonumber(segment.endedAt) or 0)-(tonumber(segment.startedAt) or 0)) end
        c.campaignStats.activeDuration=duration
    end
    c.campaignStats.activeDuration=math.max(0,math.floor(tonumber(c.campaignStats.activeDuration) or 0))
    for _,folder in pairs(c.folders) do
        folder.npcRefs=type(folder.npcRefs)=="table" and folder.npcRefs or {}
    end
    -- Migrate old many-to-many NPC references into a single file location.
    for npcID,npc in pairs(c.npcs) do
        if npc.folderID==nil then
            for folderID,folder in pairs(c.folders) do
                if folder.npcRefs and folder.npcRefs[npcID] then npc.folderID=folderID break end
            end
        end
    end
    for _,status in ipairs(c.customStatuses) do if status.folderID==nil then status.folderID=nil end end
    for _,preset in pairs(c.encounterTemplates) do if preset.folderID==nil then preset.folderID=nil end end
    return c
end

local function CampaignStats(campaign)
    campaign=EnsureCampaignSchema(campaign)
    return campaign and campaign.campaignStats or nil
end

local function TrackCampaignParticipant(campaign,name,trp,isHost)
    local s=CampaignStats(campaign);name=tostring(name or "")
    if not s or name=="" then return end
    local key=name:lower()
    s.participants[key]={name=name,trp=tostring(trp or ShortName and ShortName(name) or name):sub(1,80),isHost=isHost and true or false}
end

local function TrackCampaignNPCInteraction(campaign,npcID)
    local s=CampaignStats(campaign);npcID=tostring(npcID or "")
    if s and npcID~="" then s.npcsInteracted[npcID]=true end
end

local function TrackCampaignNPCOutcome(campaign,npcID,outcome)
    local s=CampaignStats(campaign);npcID=tostring(npcID or "");outcome=tostring(outcome or ""):upper()
    if s and npcID~="" and (outcome=="KILLED" or outcome=="SAVED") then s.npcOutcomes[npcID]=outcome end
end

local function TrackCampaignRoll(campaign,value)
    local s=CampaignStats(campaign);value=tonumber(value)
    if not s or not value then return end
    s.rollCount=(s.rollCount or 0)+1
    s.rollMin=(not s.rollMin or value<s.rollMin) and value or s.rollMin
    s.rollMax=(not s.rollMax or value>s.rollMax) and value or s.rollMax
end

local function CampaignSummary(campaign)
    local s=CampaignStats(campaign) or {};local people={}
    for _,p in pairs(s.participants or {}) do people[#people+1]={name=p.name,trp=p.trp,isHost=p.isHost and true or false} end
    table.sort(people,function(a,b)if a.isHost~=b.isHost then return a.isHost end;return tostring(a.trp or a.name)<tostring(b.trp or b.name) end)
    local interacted,killed,saved=0,0,0
    for _ in pairs(s.npcsInteracted or {}) do interacted=interacted+1 end
    for _,outcome in pairs(s.npcOutcomes or {}) do if outcome=="KILLED" then killed=killed+1 elseif outcome=="SAVED" then saved=saved+1 end end
    local activeDuration=tonumber(s.activeDuration) or 0
    local sessionCount=tonumber(s.sessionCount) or #(campaign and campaign.sessionHistory or {})
    return {participants=people,npcsInteracted=interacted,npcsKilled=killed,npcsSaved=saved,rollMin=s.rollMin,rollMax=s.rollMax,rollCount=s.rollCount or 0,sessionCount=sessionCount,activeDuration=activeDuration}
end

local function Settings()
    local s=RPAL.GetSettings and RPAL.GetSettings() or (RPAL_DB and RPAL_DB.settings) or {}
    if s.dmKitEnabled==nil then s.dmKitEnabled=false end
    return s
end

function DM.IsEnabled()
    return Settings().dmKitEnabled and true or false
end

function DM.SetEnabled(enabled)
    Settings().dmKitEnabled=enabled and true or false
    if RPAL.RefreshDMKitAvailability then RPAL.RefreshDMKitAvailability() end
    if RPAL.RefreshBookChrome then RPAL.RefreshBookChrome() end
    return Settings().dmKitEnabled
end

function DM.GetData() return Data() end

function DM.GetCampaigns()
    local db=Data()
    local result={}
    local seen={}
    for _,id in ipairs(db.campaignOrder) do
        local c=EnsureCampaignSchema(db.campaigns[id])
        if c then result[#result+1]=c;seen[id]=true end
    end
    for id,c in pairs(db.campaigns) do
        c=EnsureCampaignSchema(c)
        if not seen[id] then result[#result+1]=c end
    end
    table.sort(result,function(a,b)
        local ai,bi=0,0
        for i,id in ipairs(db.campaignOrder) do if id==a.id then ai=i elseif id==b.id then bi=i end end
        if ai>0 and bi>0 then return ai<bi end
        if ai>0 then return true end
        if bi>0 then return false end
        return tostring(a.name)<tostring(b.name)
    end)
    return result
end

function DM.GetCampaign(id)
    return EnsureCampaignSchema(Data().campaigns[id])
end

function DM.GetActiveCampaign()
    local db=Data()
    local c=EnsureCampaignSchema(db.campaigns[db.activeCampaignID])
    if c then return c end
    local list=DM.GetCampaigns()
    if list[1] then db.activeCampaignID=list[1].id return list[1] end
    return nil
end

function DM.SetActiveCampaign(id)
    local db=Data()
    if not db.campaigns[id] then return false,"Campaign not found." end
    db.activeCampaignID=id
    DM._selectedFolder=nil
    DM._selectedNPCs={}
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    return true
end

function DM.CreateCampaign(name)
    name=Trim(name)
    if name=="" then return false,"Enter a campaign name." end
    if #name>56 then name=name:sub(1,56) end
    local db=Data()
    local id=NewID("campaign")
    local c={id=id,name=name,createdAt=time(),folders={},folderOrder={},npcs={},npcOrder={},notes="",sessionHistory={},
        encounterTemplates={},encounterTemplateOrder={},pausedDMState=nil,customStatuses={},
        noteFiles={},noteOrder={},notificationPresets={},notificationPresetOrder={},rollPresets={},rollPresetOrder={},screenPresets={},screenPresetOrder={},concluded=false}
    db.campaigns[id]=c
    db.campaignOrder[#db.campaignOrder+1]=id
    db.activeCampaignID=id
    DM._selectedFolder=nil
    DM._selectedNPCs={}
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true,c
end

function DM.RenameCampaign(id,name)
    local c=DM.GetCampaign(id);name=Trim(name)
    if not c then return false,"Campaign not found." end
    if c.concluded then return false,"This Campaign is concluded and read-only." end
    if name=="" then return false,"Enter a campaign name." end
    c.name=name:sub(1,56)
    if DM.state and DM.state.role=="HOST" and DM.state.campaignID==id then
        DM.state.campaignName=c.name
        DM.BroadcastSessionHeader()
    end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true
end

function DM.DeleteCampaign(id)
    local db=Data()
    if DM.state and DM.state.role=="HOST" and DM.state.campaignID==id then return false,"End the live DM session first." end
    if not db.campaigns[id] then return false,"Campaign not found." end
    db.campaigns[id]=nil
    for i=#db.campaignOrder,1,-1 do if db.campaignOrder[i]==id then table.remove(db.campaignOrder,i) end end
    if db.activeCampaignID==id then db.activeCampaignID=db.campaignOrder[1] end
    DM._selectedFolder=nil
    DM._selectedNPCs={}
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true
end


-- ---------------------- Campaign Export / Import ----------------------
-- Campaign transfers contain preparation data only. Live/paused session state,
-- participant identities and session history are deliberately excluded.
local function CampaignHexEncode(value)
    return (tostring(value or ""):gsub(".",function(ch)return string.format("%02X",string.byte(ch))end))
end

local function CampaignHexDecode(value,maximum)
    value=tostring(value or "")
    if #value%2~=0 or value:find("[^%x]") or (maximum and #value>(maximum*2)) then return nil end
    local out={}
    for i=1,#value,2 do out[#out+1]=string.char(tonumber(value:sub(i,i+1),16)) end
    return table.concat(out)
end

local function CampaignExportRecords(campaign)
    if type(campaign)~="table" then return nil,"Campaign not found." end
    EnsureCampaignSchema(campaign)
    local records={"C|"..CampaignHexEncode(tostring(campaign.name or "Campaign"):sub(1,56)).."|"..CampaignHexEncode(tostring(campaign.notes or ""):sub(1,24000))}
    local folderSeen={}
    for _,id in ipairs(campaign.folderOrder or {}) do
        local folder=campaign.folders and campaign.folders[id]
        if folder then folderSeen[id]=true;records[#records+1]=table.concat({"F",CampaignHexEncode(id),CampaignHexEncode(folder.parentID or ""),CampaignHexEncode(tostring(folder.name or "Folder"):sub(1,64))},"|") end
    end
    for id,folder in pairs(campaign.folders or {}) do
        if not folderSeen[id] then records[#records+1]=table.concat({"F",CampaignHexEncode(id),CampaignHexEncode(folder.parentID or ""),CampaignHexEncode(tostring(folder.name or "Folder"):sub(1,64))},"|") end
    end
    local npcSeen={}
    local function emitNPC(id,npc)
        if not npc then return end;npcSeen[id]=true
        records[#records+1]=table.concat({"N",CampaignHexEncode(id),CampaignHexEncode(tostring(npc.name or "NPC"):sub(1,64)),CampaignHexEncode(tostring(npc.title or ""):sub(1,64)),tostring(ClampInt(npc.maxHP,1,1000000) or 100)},"|")
        if npc.folderID and campaign.folders[npc.folderID] then records[#records+1]=table.concat({"R",CampaignHexEncode(npc.folderID),CampaignHexEncode(id)},"|") end
    end
    for _,id in ipairs(campaign.npcOrder or {}) do emitNPC(id,campaign.npcs and campaign.npcs[id]) end
    for id,npc in pairs(campaign.npcs or {}) do if not npcSeen[id] then emitNPC(id,npc) end end
    for _,status in ipairs(campaign.customStatuses or {}) do
        if status and Trim(status.name)~="" then records[#records+1]=table.concat({"S",CampaignHexEncode(status.id or ""),CampaignHexEncode(tostring(status.name):sub(1,32)),CampaignHexEncode(status.folderID or "")},"|") end
    end
    local templates=campaign.encounterTemplates or {};local templateSeen={}
    local function emitTemplate(id,t)
        if not t then return end;templateSeen[id]=true
        records[#records+1]=table.concat({"T",CampaignHexEncode(id),CampaignHexEncode(tostring(t.name or "Encounter"):sub(1,56)),t.allowPlayerDamage and "1" or "0",t.playerHealthVisible==false and "0" or "1",tostring(ClampInt(t.healthSetupMax,1,1000000) or 0),t.initiativeHideOrder and "1" or "0",CampaignHexEncode(t.folderID or "")},"|")
        for marker,b in pairs(t.beacons or {}) do
            marker=ClampInt(marker,1,8)
            if marker and b and b.npcID and campaign.npcs and campaign.npcs[b.npcID] then
                local maxHP=ClampInt(b.maxHP,1,1000000) or (campaign.npcs[b.npcID].maxHP or 100);local current=ClampInt(b.currentHP,0,maxHP) or maxHP
                records[#records+1]=table.concat({"B",CampaignHexEncode(id),tostring(marker),CampaignHexEncode(b.npcID),tostring(current),tostring(maxHP)},"|")
            end
        end
    end
    for _,id in ipairs(campaign.encounterTemplateOrder or {}) do emitTemplate(id,templates[id]) end
    for id,t in pairs(templates) do if not templateSeen[id] then emitTemplate(id,t) end end
    for _,id in ipairs(campaign.noteOrder or {}) do local x=campaign.noteFiles[id];if x then records[#records+1]=table.concat({"Q",CampaignHexEncode(id),CampaignHexEncode(x.folderID or ""),CampaignHexEncode(tostring(x.name or "Note"):sub(1,64)),CampaignHexEncode(tostring(x.body or ""):sub(1,30000))},"|") end end
    for _,id in ipairs(campaign.notificationPresetOrder or {}) do local x=campaign.notificationPresets[id];if x then records[#records+1]=table.concat({"O",CampaignHexEncode(id),CampaignHexEncode(x.folderID or ""),CampaignHexEncode(tostring(x.name or "Notification"):sub(1,64)),CampaignHexEncode(tostring(x.title or ""):sub(1,64)),CampaignHexEncode(tostring(x.body or ""):sub(1,180))},"|") end end
    for _,id in ipairs(campaign.rollPresetOrder or {}) do local x=campaign.rollPresets[id];if x then records[#records+1]=table.concat({"L",CampaignHexEncode(id),CampaignHexEncode(x.folderID or ""),CampaignHexEncode(tostring(x.name or "Roll Check"):sub(1,64)),CampaignHexEncode(tostring(x.title or ""):sub(1,56)),CampaignHexEncode(tostring(x.info or ""):sub(1,90)),tostring(ClampInt(x.sides,1,100) or 20),tostring(x.minimum or "")},"|") end end
    for _,id in ipairs(campaign.screenPresetOrder or {}) do local x=campaign.screenPresets[id];if x then records[#records+1]=table.concat({"M",CampaignHexEncode(id),CampaignHexEncode(x.folderID or ""),CampaignHexEncode(tostring(x.name or "Screen Message"):sub(1,64)),CampaignHexEncode(tostring(x.title or ""):sub(1,64)),CampaignHexEncode(tostring(x.subtitle or ""):sub(1,120)),tostring(ClampInt(x.duration,2,12) or 5),tostring(x.presentation or "CINEMATIC")},"|") end end
    return records
end

function DM.ExportCampaign(campaignID)
    local campaign=DM.GetCampaign(campaignID) or DM.GetActiveCampaign()
    if not campaign then return nil,"Select a campaign first." end
    local records,err=CampaignExportRecords(campaign);if not records then return nil,err end
    local raw=table.concat(records,"\n")
    if #raw>250000 then return nil,"That campaign is too large to export." end
    return "RPALDMC1;"..CampaignHexEncode(raw)
end

local function ParseCampaignExport(payload)
    payload=Trim(payload)
    if #payload<12 or #payload>820000 then return nil,"Campaign export is malformed or too large." end
    local version,encoded=payload:match("^(RPALDMC1);([%x]+)$")
    if not version or not encoded then return nil,"This is not a valid ODYN DM Campaign export." end
    local raw=CampaignHexDecode(encoded,400000);if not raw then return nil,"Campaign export data is invalid." end
    local parsed={folders={},folderOrder={},npcs={},npcOrder={},memberships={},statuses={},templates={},templateOrder={},notesFiles={},notifications={},rolls={},screens={}}
    local counts={records=0,folders=0,npcs=0,statuses=0,templates=0,beacons=0,files=0}
    for line in (raw.."\n"):gmatch("([^\n]*)\n") do
        if line~="" then
            counts.records=counts.records+1;if counts.records>2600 then return nil,"Campaign export contains too many records." end
            local fields={};for part in (line.."|"):gmatch("(.-)|") do fields[#fields+1]=part end
            local kind=fields[1]
            if kind=="C" then
                if parsed.name then return nil,"Campaign export has more than one header." end
                parsed.name=CampaignHexDecode(fields[2],56);parsed.notes=CampaignHexDecode(fields[3] or "",24000)
                if not parsed.name or Trim(parsed.name)=="" or parsed.notes==nil then return nil,"Campaign header is invalid." end
            elseif kind=="F" then
                counts.folders=counts.folders+1;if counts.folders>300 then return nil,"Campaign has too many folders." end
                local oldID=CampaignHexDecode(fields[2],96);local parent=CampaignHexDecode(fields[3] or "",96);local name=CampaignHexDecode(fields[4],64)
                if not oldID or oldID=="" or parsed.folders[oldID] or not name or Trim(name)=="" then return nil,"Campaign folder record is invalid." end
                parsed.folders[oldID]={oldID=oldID,parentOld=parent~="" and parent or nil,name=name};parsed.folderOrder[#parsed.folderOrder+1]=oldID
            elseif kind=="N" then
                counts.npcs=counts.npcs+1;if counts.npcs>600 then return nil,"Campaign has too many NPCs." end
                local oldID=CampaignHexDecode(fields[2],96);local name=CampaignHexDecode(fields[3],64);local title=CampaignHexDecode(fields[4] or "",64);local maxHP=ClampInt(fields[5],1,1000000)
                if not oldID or oldID=="" or parsed.npcs[oldID] or not name or Trim(name)=="" or title==nil or not maxHP then return nil,"Campaign NPC record is invalid." end
                parsed.npcs[oldID]={oldID=oldID,name=name,title=title,maxHP=maxHP};parsed.npcOrder[#parsed.npcOrder+1]=oldID
            elseif kind=="R" then
                local folderID=CampaignHexDecode(fields[2],96);local npcID=CampaignHexDecode(fields[3],96);if not folderID or not npcID then return nil,"Campaign folder membership is invalid." end
                parsed.memberships[#parsed.memberships+1]={folder=folderID,npc=npcID}
            elseif kind=="S" then
                counts.statuses=counts.statuses+1;if counts.statuses>160 then return nil,"Campaign has too many custom statuses." end
                local oldID=CampaignHexDecode(fields[2] or "",96);local name=CampaignHexDecode(fields[3],32);local folder=CampaignHexDecode(fields[4] or "",96)
                if not name or Trim(name)=="" or folder==nil then return nil,"Campaign status record is invalid." end
                parsed.statuses[#parsed.statuses+1]={oldID=oldID or "",name=name,folderOld=folder~="" and folder or nil}
            elseif kind=="T" then
                counts.templates=counts.templates+1;if counts.templates>200 then return nil,"Campaign has too many encounter presets." end
                local oldID=CampaignHexDecode(fields[2],96);local name=CampaignHexDecode(fields[3],56);local health=ClampInt(fields[6],0,1000000);local folder=CampaignHexDecode(fields[8] or "",96)
                if not oldID or oldID=="" or parsed.templates[oldID] or not name or Trim(name)=="" or health==nil or folder==nil then return nil,"Campaign encounter preset is invalid." end
                parsed.templates[oldID]={oldID=oldID,name=name,allowPlayerDamage=fields[4]=="1",playerHealthVisible=fields[5]~="0",healthSetupMax=health>0 and health or nil,initiativeHideOrder=fields[7]=="1",folderOld=folder~="" and folder or nil,beacons={}};parsed.templateOrder[#parsed.templateOrder+1]=oldID
            elseif kind=="B" then
                counts.beacons=counts.beacons+1;if counts.beacons>800 then return nil,"Campaign has too many preset beacon records." end
                local templateID=CampaignHexDecode(fields[2],96);local marker=ClampInt(fields[3],1,8);local npcID=CampaignHexDecode(fields[4],96);local current=ClampInt(fields[5],0,1000000);local maxHP=ClampInt(fields[6],1,1000000);local t=templateID and parsed.templates[templateID]
                if not t or not marker or not npcID or not current or not maxHP or current>maxHP then return nil,"Campaign preset beacon record is invalid." end;t.beacons[marker]={npcOld=npcID,currentHP=current,maxHP=maxHP}
            elseif kind=="Q" then
                counts.files=counts.files+1;local oldID=CampaignHexDecode(fields[2],96);local folder=CampaignHexDecode(fields[3] or "",96);local name=CampaignHexDecode(fields[4],64);local body=CampaignHexDecode(fields[5] or "",30000)
                if not oldID or oldID=="" or not name or Trim(name)=="" or folder==nil or body==nil then return nil,"Campaign note file is invalid." end;parsed.notesFiles[#parsed.notesFiles+1]={oldID=oldID,folderOld=folder~="" and folder or nil,name=name,body=body}
            elseif kind=="O" then
                counts.files=counts.files+1;local oldID=CampaignHexDecode(fields[2],96);local folder=CampaignHexDecode(fields[3] or "",96);local name=CampaignHexDecode(fields[4],64);local title=CampaignHexDecode(fields[5],64);local body=CampaignHexDecode(fields[6] or "",180)
                if not oldID or not name or Trim(name)=="" or not title or Trim(title)=="" or folder==nil or body==nil then return nil,"Campaign notification preset is invalid." end;parsed.notifications[#parsed.notifications+1]={oldID=oldID,folderOld=folder~="" and folder or nil,name=name,title=title,body=body}
            elseif kind=="L" then
                counts.files=counts.files+1;local oldID=CampaignHexDecode(fields[2],96);local folder=CampaignHexDecode(fields[3] or "",96);local name=CampaignHexDecode(fields[4],64);local title=CampaignHexDecode(fields[5],56);local info=CampaignHexDecode(fields[6] or "",90);local sides=ClampInt(fields[7],1,100);local minimum=fields[8]~="" and ClampInt(fields[8],1,sides or 100) or nil
                if not oldID or not name or Trim(name)=="" or not title or Trim(title)=="" or folder==nil or info==nil or not VALID_DICE[sides] then return nil,"Campaign roll preset is invalid." end;parsed.rolls[#parsed.rolls+1]={oldID=oldID,folderOld=folder~="" and folder or nil,name=name,title=title,info=info,sides=sides,minimum=minimum}
            elseif kind=="M" then
                counts.files=counts.files+1;local oldID=CampaignHexDecode(fields[2],96);local folder=CampaignHexDecode(fields[3] or "",96);local name=CampaignHexDecode(fields[4],64);local title=CampaignHexDecode(fields[5],64);local subtitle=CampaignHexDecode(fields[6] or "",120);local duration=ClampInt(fields[7],2,12);local presentation=tostring(fields[8] or "CINEMATIC"):upper()
                if not oldID or not name or Trim(name)=="" or not title or Trim(title)=="" or folder==nil or subtitle==nil or not duration then return nil,"Campaign screen preset is invalid." end;parsed.screens[#parsed.screens+1]={oldID=oldID,folderOld=folder~="" and folder or nil,name=name,title=title,subtitle=subtitle,duration=duration,presentation=presentation}
            else return nil,"Campaign export contains an unknown record." end
        end
    end
    if not parsed.name then return nil,"Campaign export has no header." end
    for _,folder in pairs(parsed.folders) do if folder.parentOld and not parsed.folders[folder.parentOld] then return nil,"Campaign contains a broken folder link." end end
    for _,m in ipairs(parsed.memberships) do if not parsed.folders[m.folder] or not parsed.npcs[m.npc] then return nil,"Campaign contains a broken NPC folder link." end end
    local function validFolder(old)return not old or parsed.folders[old]~=nil end
    for _,x in ipairs(parsed.statuses) do if not validFolder(x.folderOld) then return nil,"Campaign status refers to a missing folder." end end
    for _,oldID in ipairs(parsed.templateOrder) do local t=parsed.templates[oldID];if not validFolder(t.folderOld) then return nil,"Campaign preset refers to a missing folder." end;for _,b in pairs(t.beacons or {}) do if not parsed.npcs[b.npcOld] then return nil,"Campaign preset refers to a missing NPC." end end end
    for _,set in ipairs({parsed.notesFiles,parsed.notifications,parsed.rolls,parsed.screens}) do for _,x in ipairs(set) do if not validFolder(x.folderOld) then return nil,"Campaign file refers to a missing folder." end end end
    parsed.counts=counts;return parsed
end

function DM.GetCampaignExchangePreview(payload)
    local parsed,err=ParseCampaignExport(payload);if not parsed then return nil,err end
    return {name=parsed.name,folders=parsed.counts.folders,npcs=parsed.counts.npcs,statuses=parsed.counts.statuses,templates=parsed.counts.templates,files=parsed.counts.files}
end

local function UniqueImportedCampaignName(base)
    base=Trim(base);if base=="" then base="Imported Campaign" end
    local used={};for _,c in ipairs(DM.GetCampaigns()) do used[tostring(c.name or ""):lower()]=true end
    if not used[base:lower()] then return base:sub(1,56) end
    local root=(base.." (Imported)"):sub(1,56);if not used[root:lower()] then return root end
    for i=2,99 do local suffix=" (Imported "..i..")";local candidate=base:sub(1,math.max(1,56-#suffix))..suffix;if not used[candidate:lower()] then return candidate end end
    return (base:sub(1,42).." "..tostring(time())):sub(1,56)
end

function DM.ImportCampaign(payload)
    local parsed,err=ParseCampaignExport(payload);if not parsed then return false,err end
    local db=Data();local campaignID=NewID("campaign")
    local c={id=campaignID,name=UniqueImportedCampaignName(parsed.name),createdAt=time(),folders={},folderOrder={},npcs={},npcOrder={},notes=parsed.notes or "",sessionHistory={},encounterTemplates={},encounterTemplateOrder={},pausedDMState=nil,customStatuses={},noteFiles={},noteOrder={},notificationPresets={},notificationPresetOrder={},rollPresets={},rollPresetOrder={},screenPresets={},screenPresetOrder={},concluded=false}
    local folderMap={};for _,oldID in ipairs(parsed.folderOrder) do local src=parsed.folders[oldID];local id=NewID("folder");folderMap[oldID]=id;c.folders[id]={id=id,name=src.name,parentID=nil,createdAt=time(),npcRefs={}};c.folderOrder[#c.folderOrder+1]=id end
    for oldID,src in pairs(parsed.folders) do local id=folderMap[oldID];if id and src.parentOld then c.folders[id].parentID=folderMap[src.parentOld] end end
    local npcMap={};for _,oldID in ipairs(parsed.npcOrder) do local src=parsed.npcs[oldID];local id=NewID("npc");npcMap[oldID]=id;c.npcs[id]={id=id,name=src.name,title=src.title,maxHP=src.maxHP,createdAt=time(),folderID=nil};c.npcOrder[#c.npcOrder+1]=id end
    for _,m in ipairs(parsed.memberships) do local folderID=folderMap[m.folder];local npcID=npcMap[m.npc];local folder=c.folders[folderID];if folder and npcID then folder.npcRefs[npcID]=true;c.npcs[npcID].folderID=folderID end end
    for _,src in ipairs(parsed.statuses) do c.customStatuses[#c.customStatuses+1]={id=NewID("status"),name=src.name,createdAt=time(),folderID=src.folderOld and folderMap[src.folderOld] or nil} end
    for _,oldID in ipairs(parsed.templateOrder) do
        local src=parsed.templates[oldID];local id=NewID("encounter");local t={id=id,name=src.name,createdAt=time(),folderID=src.folderOld and folderMap[src.folderOld] or nil,beacons={},allowPlayerDamage=src.allowPlayerDamage,playerHealthVisible=src.playerHealthVisible,healthSetupMax=src.healthSetupMax,initiativeHideOrder=src.initiativeHideOrder}
        for marker,b in pairs(src.beacons or {}) do local npcID=npcMap[b.npcOld];if npcID then t.beacons[marker]={npcID=npcID,currentHP=b.currentHP,maxHP=b.maxHP,name=c.npcs[npcID].name,title=c.npcs[npcID].title} end end;c.encounterTemplates[id]=t;c.encounterTemplateOrder[#c.encounterTemplateOrder+1]=id
    end
    for _,src in ipairs(parsed.notesFiles or {}) do local id=NewID("note");c.noteFiles[id]={id=id,name=src.name,body=src.body,folderID=src.folderOld and folderMap[src.folderOld] or nil,createdAt=time(),updatedAt=time()};c.noteOrder[#c.noteOrder+1]=id end
    for _,src in ipairs(parsed.notifications or {}) do local id=NewID("notify");c.notificationPresets[id]={id=id,name=src.name,title=src.title,body=src.body,folderID=src.folderOld and folderMap[src.folderOld] or nil,createdAt=time()};c.notificationPresetOrder[#c.notificationPresetOrder+1]=id end
    for _,src in ipairs(parsed.rolls or {}) do local id=NewID("rollpreset");c.rollPresets[id]={id=id,name=src.name,title=src.title,info=src.info,sides=src.sides,minimum=src.minimum,folderID=src.folderOld and folderMap[src.folderOld] or nil,createdAt=time()};c.rollPresetOrder[#c.rollPresetOrder+1]=id end
    for _,src in ipairs(parsed.screens or {}) do local id=NewID("screenpreset");c.screenPresets[id]={id=id,name=src.name,title=src.title,subtitle=src.subtitle,duration=src.duration,presentation=src.presentation,folderID=src.folderOld and folderMap[src.folderOld] or nil,createdAt=time()};c.screenPresetOrder[#c.screenPresetOrder+1]=id end
    db.campaigns[campaignID]=c;db.campaignOrder[#db.campaignOrder+1]=campaignID;db.activeCampaignID=campaignID;DM._selectedFolder=nil;DM._selectedNPCs={}
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end;if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    return true,c
end

function DM.CreateSubFolder(campaignID,name,parentID)
    local c=DM.GetCampaign(campaignID);name=Trim(name)
    if not c then return false,"Campaign not found." end
    if c.concluded then return false,"This Campaign is concluded and read-only." end
    if name=="" then return false,"Enter a folder name." end
    if parentID and not c.folders[parentID] then parentID=nil end
    local id=NewID("folder")
    c.folders[id]={id=id,name=name:sub(1,64),parentID=parentID,createdAt=time(),npcRefs={}}
    c.folderOrder[#c.folderOrder+1]=id
    DM._selectedFolder=id
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true,c.folders[id]
end

function DM.RenameSubFolder(campaignID,folderID,name)
    local c=DM.GetCampaign(campaignID);local folder=c and c.folders and c.folders[folderID]
    name=Trim(name)
    if c and c.concluded then return false,"This Campaign is concluded and read-only." end
    if not folder then return false,"Folder not found." end
    if name=="" then return false,"Enter a folder name." end
    folder.name=name:sub(1,64)
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true,folder
end

local function FolderIsDescendant(c,possibleChild,ancestor)
    local cursor=possibleChild;local safety=0
    while cursor and safety<100 do
        if cursor==ancestor then return true end
        local f=c.folders and c.folders[cursor];cursor=f and f.parentID or nil;safety=safety+1
    end
    return false
end

function DM.MoveSubFolder(campaignID,folderID,newParentID)
    local c=DM.GetCampaign(campaignID);local folder=c and c.folders and c.folders[folderID]
    if c and c.concluded then return false,"This Campaign is concluded and read-only." end
    if not folder then return false,"Folder not found." end
    if newParentID=="" then newParentID=nil end
    if newParentID then
        if not c.folders[newParentID] then return false,"Destination folder not found." end
        if newParentID==folderID or FolderIsDescendant(c,newParentID,folderID) then return false,"A folder cannot be moved inside itself." end
    end
    folder.parentID=newParentID
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true
end

local function SetNPCFolder(c,npcID,folderID)
    local npc=c.npcs and c.npcs[npcID];if not npc then return false end
    if folderID and not c.folders[folderID] then folderID=nil end
    for _,folder in pairs(c.folders or {}) do if folder.npcRefs then folder.npcRefs[npcID]=nil end end
    npc.folderID=folderID
    if folderID and c.folders[folderID] then c.folders[folderID].npcRefs[npcID]=true end
    return true
end

function DM.DeleteSubFolder(campaignID,folderID)
    local c=DM.GetCampaign(campaignID)
    if c and c.concluded then return false,"This Campaign is concluded and read-only." end
    if not c or not c.folders or not c.folders[folderID] then return false,"Folder not found." end
    c.folders[folderID]=nil
    for i=#(c.folderOrder or {}),1,-1 do if c.folderOrder[i]==folderID then table.remove(c.folderOrder,i) end end
    -- Deleting an organizer never deletes its files: direct children and files
    -- are promoted to the Campaign root so nothing disappears unexpectedly.
    for _,folder in pairs(c.folders or {}) do if folder.parentID==folderID then folder.parentID=nil end end
    for _,npc in pairs(c.npcs or {}) do if npc.folderID==folderID then npc.folderID=nil end end
    for _,item in pairs(c.noteFiles or {}) do if item.folderID==folderID then item.folderID=nil end end
    for _,item in pairs(c.encounterTemplates or {}) do if item.folderID==folderID then item.folderID=nil end end
    for _,item in pairs(c.notificationPresets or {}) do if item.folderID==folderID then item.folderID=nil end end
    for _,item in pairs(c.rollPresets or {}) do if item.folderID==folderID then item.folderID=nil end end
    for _,item in pairs(c.screenPresets or {}) do if item.folderID==folderID then item.folderID=nil end end
    for _,item in ipairs(c.customStatuses or {}) do if item.folderID==folderID then item.folderID=nil end end
    if DM._selectedFolder==folderID then DM._selectedFolder=nil end
    DM._selectedNPCs={}
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true
end

local FILE_COLLECTIONS={
    NOTE={tableKey="noteFiles",orderKey="noteOrder"},
    ENCOUNTER={tableKey="encounterTemplates",orderKey="encounterTemplateOrder"},
    NOTIFICATION={tableKey="notificationPresets",orderKey="notificationPresetOrder"},
    ROLL={tableKey="rollPresets",orderKey="rollPresetOrder"},
    SCREEN={tableKey="screenPresets",orderKey="screenPresetOrder"},
}

function DM.MoveCampaignFile(campaignID,kind,id,folderID)
    local c=DM.GetCampaign(campaignID);kind=tostring(kind or ""):upper()
    if not c then return false,"Campaign not found." end
    if c.concluded then return false,"This Campaign is concluded and read-only." end
    if folderID and not c.folders[folderID] then folderID=nil end
    if kind=="NPC" then
        if not SetNPCFolder(c,id,folderID) then return false,"NPC not found." end
    elseif kind=="DEBUFF" then
        local found
        for _,item in ipairs(c.customStatuses or {}) do if item.id==id then item.folderID=folderID;found=item break end end
        if not found then return false,"Debuff preset not found." end
    else
        local def=FILE_COLLECTIONS[kind];local item=def and c[def.tableKey] and c[def.tableKey][id]
        if not item then return false,"Campaign file not found." end
        item.folderID=folderID
    end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true
end

function DM.GetCampaignFiles(campaignID,folderID,recursive)
    local c=DM.GetCampaign(campaignID);local out={}
    if not c then return out end
    local allowedFolders=nil
    if folderID then
        allowedFolders={[folderID]=true}
        if recursive then
            local changed=true
            while changed do
                changed=false
                for id,folder in pairs(c.folders or {}) do
                    if folder.parentID and allowedFolders[folder.parentID] and not allowedFolders[id] then allowedFolders[id]=true;changed=true end
                end
            end
        end
    end
    local function allowed(fid)
        if folderID==nil then return recursive and true or fid==nil end
        return allowedFolders and allowedFolders[fid] or false
    end
    for _,id in ipairs(c.npcOrder or {}) do local item=c.npcs[id];if item and allowed(item.folderID) then out[#out+1]={kind="NPC",id=id,name=item.name,folderID=item.folderID,data=item} end end
    for _,id in ipairs(c.noteOrder or {}) do local item=c.noteFiles[id];if item and allowed(item.folderID) then out[#out+1]={kind="NOTE",id=id,name=item.name,folderID=item.folderID,data=item} end end
    for _,id in ipairs(c.encounterTemplateOrder or {}) do local item=c.encounterTemplates[id];if item and allowed(item.folderID) then out[#out+1]={kind="ENCOUNTER",id=id,name=item.name,folderID=item.folderID,data=item} end end
    for _,id in ipairs(c.notificationPresetOrder or {}) do local item=c.notificationPresets[id];if item and allowed(item.folderID) then out[#out+1]={kind="NOTIFICATION",id=id,name=item.name,folderID=item.folderID,data=item} end end
    for _,id in ipairs(c.rollPresetOrder or {}) do local item=c.rollPresets[id];if item and allowed(item.folderID) then out[#out+1]={kind="ROLL",id=id,name=item.name,folderID=item.folderID,data=item} end end
    for _,id in ipairs(c.screenPresetOrder or {}) do local item=c.screenPresets[id];if item and allowed(item.folderID) then out[#out+1]={kind="SCREEN",id=id,name=item.name,folderID=item.folderID,data=item} end end
    for _,item in ipairs(c.customStatuses or {}) do if item and allowed(item.folderID) then out[#out+1]={kind="DEBUFF",id=item.id,name=item.name,folderID=item.folderID,data=item} end end
    table.sort(out,function(a,b)
        local ak,bk=tostring(a.kind),tostring(b.kind)
        if ak~=bk then return ak<bk end
        return tostring(a.name or ""):lower()<tostring(b.name or ""):lower()
    end)
    return out
end

function DM.CreateNoteFile(campaignID,name,body,folderID)
    local c=DM.GetCampaign(campaignID);name=Trim(name)
    if not c then return false,"Campaign not found." end
    if c.concluded then return false,"This Campaign is concluded and read-only." end
    if name=="" then return false,"Enter a note name." end
    local id=NewID("note");local item={id=id,name=name:sub(1,64),body=tostring(body or ""):sub(1,30000),folderID=(folderID and c.folders[folderID]) and folderID or nil,createdAt=time(),updatedAt=time()}
    c.noteFiles[id]=item;c.noteOrder[#c.noteOrder+1]=id
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true,item
end

function DM.UpdateNoteFile(campaignID,id,changes)
    local c=DM.GetCampaign(campaignID);local item=c and c.noteFiles and c.noteFiles[id]
    if c and c.concluded then return false,"This Campaign is concluded and read-only." end
    if not item then return false,"Note not found." end
    changes=type(changes)=="table" and changes or {}
    if changes.name~=nil and Trim(changes.name)~="" then item.name=Trim(changes.name):sub(1,64) end
    if changes.body~=nil then item.body=tostring(changes.body):sub(1,30000) end
    item.updatedAt=time();if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end;return true,item
end

function DM.DeleteNoteFile(campaignID,id)
    local c=DM.GetCampaign(campaignID);if c and c.concluded then return false,"This Campaign is concluded and read-only." end;if not c or not c.noteFiles[id] then return false,"Note not found." end
    c.noteFiles[id]=nil;for i=#c.noteOrder,1,-1 do if c.noteOrder[i]==id then table.remove(c.noteOrder,i) end end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end;return true
end

function DM.CreateNotificationPreset(campaignID,name,title,body,folderID)
    local c=DM.GetCampaign(campaignID);name=Trim(name);title=Trim(title)
    if not c then return false,"Campaign not found." end
    if c.concluded then return false,"This Campaign is concluded and read-only." end;if name=="" or title=="" then return false,"Enter a preset name and notification title." end
    local id=NewID("notify");local item={id=id,name=name:sub(1,64),title=title:sub(1,64),body=Trim(body):sub(1,180),folderID=(folderID and c.folders[folderID]) and folderID or nil,createdAt=time()}
    c.notificationPresets[id]=item;c.notificationPresetOrder[#c.notificationPresetOrder+1]=id;if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end;return true,item
end

function DM.CreateRollPreset(campaignID,name,title,info,sides,minimum,folderID)
    local c=DM.GetCampaign(campaignID);name=Trim(name);title=Trim(title);sides=ClampInt(sides,1,100)
    if not c then return false,"Campaign not found." end
    if c.concluded then return false,"This Campaign is concluded and read-only." end;if name=="" or title=="" then return false,"Enter a preset name and roll title." end;if not VALID_DICE[sides] then return false,"Choose a supported die." end
    if minimum~=nil and Trim(minimum)~="" then minimum=ClampInt(minimum,1,sides) else minimum=nil end
    local id=NewID("rollpreset");local item={id=id,name=name:sub(1,64),title=title:sub(1,56),info=Trim(info):sub(1,90),sides=sides,minimum=minimum,folderID=(folderID and c.folders[folderID]) and folderID or nil,createdAt=time()}
    c.rollPresets[id]=item;c.rollPresetOrder[#c.rollPresetOrder+1]=id;if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end;return true,item
end

function DM.CreateScreenPreset(campaignID,name,title,subtitle,duration,presentation,folderID)
    local c=DM.GetCampaign(campaignID);name=Trim(name);title=Trim(title)
    if not c then return false,"Campaign not found." end
    if c.concluded then return false,"This Campaign is concluded and read-only." end;if name=="" or title=="" then return false,"Enter a preset name and screen title." end
    presentation=tostring(presentation or "CINEMATIC"):upper();if presentation~="SUBTLE" and presentation~="WARNING" then presentation="CINEMATIC" end
    local id=NewID("screenpreset");local item={id=id,name=name:sub(1,64),title=title:sub(1,64),subtitle=Trim(subtitle):sub(1,120),duration=ClampInt(duration,2,12) or 5,presentation=presentation,folderID=(folderID and c.folders[folderID]) and folderID or nil,createdAt=time()}
    c.screenPresets[id]=item;c.screenPresetOrder[#c.screenPresetOrder+1]=id;if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end;return true,item
end

function DM.RenameCampaignFile(campaignID,kind,id,name)
    local c=DM.GetCampaign(campaignID);kind=tostring(kind or ""):upper();name=Trim(name)
    if c and c.concluded then return false,"This Campaign is concluded and read-only." end
    if not c or name=="" then return false,"Enter a file name." end
    if kind=="NPC" then return DM.UpdateNPC(campaignID,id,{name=name}) end
    if kind=="DEBUFF" then for _,item in ipairs(c.customStatuses or {}) do if item.id==id then item.name=name:sub(1,32);if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end;return true,item end end return false,"Debuff preset not found." end
    local def=FILE_COLLECTIONS[kind];local item=def and c[def.tableKey] and c[def.tableKey][id];if not item then return false,"Campaign file not found." end
    item.name=name:sub(1,64);if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end;return true,item
end

function DM.DeleteCampaignFile(campaignID,kind,id)
    kind=tostring(kind or ""):upper();local c=DM.GetCampaign(campaignID);if not c then return false,"Campaign not found." end;if c.concluded then return false,"This Campaign is concluded and read-only." end
    if kind=="NPC" then return DM.DeleteNPC(campaignID,id) end
    if kind=="NOTE" then return DM.DeleteNoteFile(campaignID,id) end
    if kind=="ENCOUNTER" then return DM.DeleteEncounterTemplate(campaignID,id) end
    if kind=="DEBUFF" then for i=#(c.customStatuses or {}),1,-1 do if c.customStatuses[i].id==id then table.remove(c.customStatuses,i);if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end;return true end end return false,"Debuff preset not found." end
    local def=FILE_COLLECTIONS[kind];local item=def and c[def.tableKey] and c[def.tableKey][id];if not item then return false,"Campaign file not found." end
    c[def.tableKey][id]=nil;for i=#c[def.orderKey],1,-1 do if c[def.orderKey][i]==id then table.remove(c[def.orderKey],i) end end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end;return true
end

local function CopyAssetIntoFolder(c,kind,src,folderID,nameOverride)
    if not c or not src then return nil end
    kind=tostring(kind or ""):upper();local item=CopyTable(src);item.id=NewID(kind:lower());item.createdAt=time();item.folderID=folderID
    item.name=tostring(nameOverride or ((src.name or kind).." Copy")):sub(1,80)
    if kind=="NPC" then c.npcs[item.id]=item;c.npcOrder[#c.npcOrder+1]=item.id;if folderID and c.folders[folderID] then c.folders[folderID].npcRefs[item.id]=true end
    elseif kind=="NOTE" then c.noteFiles[item.id]=item;c.noteOrder[#c.noteOrder+1]=item.id
    elseif kind=="ENCOUNTER" then c.encounterTemplates[item.id]=item;c.encounterTemplateOrder[#c.encounterTemplateOrder+1]=item.id
    elseif kind=="NOTIFICATION" then c.notificationPresets[item.id]=item;c.notificationPresetOrder[#c.notificationPresetOrder+1]=item.id
    elseif kind=="ROLL" then c.rollPresets[item.id]=item;c.rollPresetOrder[#c.rollPresetOrder+1]=item.id
    elseif kind=="SCREEN" then c.screenPresets[item.id]=item;c.screenPresetOrder[#c.screenPresetOrder+1]=item.id
    elseif kind=="DEBUFF" then c.customStatuses[#c.customStatuses+1]=item
    else return nil end
    return item
end

function DM.DuplicateCampaignFile(campaignID,kind,id)
    local c=DM.GetCampaign(campaignID);kind=tostring(kind or ""):upper();if not c then return false,"Campaign not found." end
    if c.concluded then return false,"This Campaign is concluded and read-only." end
    local src
    if kind=="NPC" then src=c.npcs[id]
    elseif kind=="NOTE" then src=c.noteFiles[id]
    elseif kind=="ENCOUNTER" then src=c.encounterTemplates[id]
    elseif kind=="NOTIFICATION" then src=c.notificationPresets[id]
    elseif kind=="ROLL" then src=c.rollPresets[id]
    elseif kind=="SCREEN" then src=c.screenPresets[id]
    elseif kind=="DEBUFF" then for _,v in ipairs(c.customStatuses or {}) do if v.id==id then src=v break end end end
    if not src then return false,"File not found." end
    local copy=CopyAssetIntoFolder(c,kind,src,src.folderID,(src.name or "File").." Copy")
    if not copy then return false,"That file type cannot be duplicated." end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true,copy
end

function DM.DuplicateSubFolder(campaignID,folderID)
    local c=DM.GetCampaign(campaignID);local src=c and c.folders and c.folders[folderID]
    if not src then return false,"Folder not found." end;if c.concluded then return false,"This Campaign is concluded and read-only." end
    local map={}
    local function cloneFolder(oldID,newParent,isRoot)
        local old=c.folders[oldID];if not old then return end
        local newID=NewID("folder");map[oldID]=newID;c.folders[newID]={id=newID,name=(old.name..(isRoot and " Copy" or "")):sub(1,64),parentID=newParent,createdAt=time(),npcRefs={}};c.folderOrder[#c.folderOrder+1]=newID
        for _,childID in ipairs(c.folderOrder) do local child=c.folders[childID];if child and child.parentID==oldID and not map[childID] then cloneFolder(childID,newID,false) end end
    end
    cloneFolder(folderID,src.parentID,true)
    local originalFiles=DM.GetCampaignFiles(campaignID,folderID,true)
    for _,it in ipairs(originalFiles) do local target=map[it.folderID];if target then CopyAssetIntoFolder(c,it.kind,it.data,target,it.name) end end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true,c.folders[map[folderID]]
end

function DM.DuplicateCampaign(campaignID)
    local payload,err=DM.ExportCampaign(campaignID);if not payload then return false,err end
    local ok,campaign=DM.ImportCampaign(payload);if not ok then return false,campaign end
    local original=DM.GetCampaign(campaignID);if campaign and original then DM.RenameCampaign(campaign.id,(original.name.." Copy"):sub(1,56)) end
    return true,campaign
end

function DM.CreateNPC(campaignID,name,maxHP,title,folderID)
    local c=DM.GetCampaign(campaignID);name=Trim(name)
    if not c then return false,"Campaign not found." end
    if c.concluded then return false,"This Campaign is concluded and read-only." end
    if name=="" then return false,"Enter an NPC name." end
    maxHP=ClampInt(maxHP or 100,1,1000000)
    if not maxHP then return false,"Maximum health must be between 1 and 1,000,000." end
    c.npcs=c.npcs or {};c.npcOrder=c.npcOrder or {}
    local id=NewID("npc")
    local npc={id=id,name=name:sub(1,64),title=Trim(title):sub(1,64),maxHP=maxHP,createdAt=time(),folderID=(folderID and c.folders and c.folders[folderID]) and folderID or nil}
    c.npcs[id]=npc;c.npcOrder[#c.npcOrder+1]=id
    if npc.folderID then c.folders[npc.folderID].npcRefs[id]=true end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true,npc
end

function DM.UpdateNPC(campaignID,npcID,changes)
    local c=DM.GetCampaign(campaignID);local npc=c and c.npcs and c.npcs[npcID]
    if c and c.concluded then return false,"This Campaign is concluded and read-only." end
    if not npc then return false,"NPC not found." end
    if changes.name~=nil and Trim(changes.name)~="" then npc.name=Trim(changes.name):sub(1,64) end
    if changes.title~=nil then npc.title=Trim(changes.title):sub(1,64) end
    if changes.maxHP~=nil then
        local v=ClampInt(changes.maxHP,1,1000000);if not v then return false,"Invalid maximum health." end
        npc.maxHP=v
    end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true
end

function DM.DeleteNPC(campaignID,npcID)
    local c=DM.GetCampaign(campaignID)
    if c and c.concluded then return false,"This Campaign is concluded and read-only." end
    if not c or not c.npcs or not c.npcs[npcID] then return false,"NPC not found." end
    if DM.state and DM.state.role=="HOST" and DM.state.campaignID==campaignID then
        for _,b in pairs(DM.state.beacons or {}) do if b and b.npcID==npcID then return false,"Undeploy this NPC before deleting its file." end end
    end
    c.npcs[npcID]=nil
    for i=#(c.npcOrder or {}),1,-1 do if c.npcOrder[i]==npcID then table.remove(c.npcOrder,i) end end
    for _,folder in pairs(c.folders or {}) do if folder.npcRefs then folder.npcRefs[npcID]=nil end end
    DM._selectedNPCs[npcID]=nil
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true
end

function DM.GetNPCs(campaignID,folderID)
    local c=DM.GetCampaign(campaignID);local result={}
    if not c then return result end
    for _,id in ipairs(c.npcOrder or {}) do
        local npc=c.npcs and c.npcs[id]
        if npc and (folderID==nil or npc.folderID==folderID) then result[#result+1]=npc end
    end
    return result
end

local function ParticipantKey(state,name)
    if not state or not name then return nil end
    if state.participants and state.participants[name] then return name end
    for key in pairs(state.participants or {}) do if SamePlayer(key,name) then return key end end
    return nil
end

local function ParticipantCount(state)
    local n=0;for _ in pairs(state and state.participants or {}) do n=n+1 end;return n
end

local function IsHost(state)
    return state and state.role=="HOST" and SamePlayer(state.host,LocalKey())
end

function DM.IsHost() return IsHost(DM.state) end
function DM.IsParticipant() return DM.state and DM.state.role=="PARTICIPANT" end
function DM.GetState() return DM.state end

local function EnsureHostState()
    local state=DM.state
    if not IsHost(state) then return nil,"You are not hosting a DM session." end
    return state
end

local function LogLocal(state,text,visibility,target,kind)
    if not state then return end
    state.log=state.log or {}
    local entry={id=NewID("log"),t=time(),text=tostring(text or ""),visibility=visibility or "HOST_ONLY",target=target,kind=kind or "SYSTEM"}
    state.log[#state.log+1]=entry
    while #state.log>MAX_LOG do table.remove(state.log,1) end
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return entry
end

local function Broadcast(kind,...)
    local state=DM.state
    if not IsHost(state) then return end
    for name,p in pairs(state.participants or {}) do
        if not p.disconnected and not SamePlayer(name,LocalKey()) then Send(name,kind,state.sessionID,...) end
    end
end

local function SendPublicLogTo(target,entry)
    if not entry then return end
    Send(target,"LOG",DM.state and DM.state.sessionID or "",entry.id,entry.t,entry.kind,tostring(entry.text or ""):sub(1,140))
end

local function PublicLog(text,kind,targetOnly)
    local state=DM.state;if not state then return end
    local visibility=targetOnly and "TARGET_ONLY" or "PUBLIC"
    local entry=LogLocal(state,text,visibility,targetOnly,kind)
    if not IsHost(state) then return entry end
    if targetOnly then
        if not SamePlayer(targetOnly,LocalKey()) then SendPublicLogTo(targetOnly,entry) end
    else
        for name,p in pairs(state.participants or {}) do
            if not p.disconnected and not SamePlayer(name,LocalKey()) then SendPublicLogTo(name,entry) end
        end
    end
    return entry
end

function DM.GetVisibleLog()
    local state=DM.state;local result={}
    if not state then return result end
    local me=LocalKey()
    for _,e in ipairs(state.log or {}) do
        if state.role=="HOST" or e.visibility=="PUBLIC" or (e.visibility=="TARGET_ONLY" and SamePlayer(e.target,me)) then
            result[#result+1]=e
        end
    end
    return result
end

function DM.GetNotifications()
    return DM.state and DM.state.notifications or {}
end

local function AddNotificationLocal(state,id,t,title,body,target)
    state.notifications=state.notifications or {}
    state.notifications[#state.notifications+1]={id=id,t=t,title=title,body=body,target=target}
    while #state.notifications>MAX_NOTIFICATIONS do table.remove(state.notifications,1) end
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
end

function DM.BroadcastSessionHeader(target)
    local state=DM.state;if not IsHost(state) then return end
    local function one(name)
        Send(name,"SESSION",state.sessionID,state.campaignID,state.campaignName,state.host,LocalTRPName(),state.allowPlayerDamage and "1" or "0",state.playerHealthVisible and "1" or "0")
    end
    if target then one(target) else
        for name,p in pairs(state.participants or {}) do if not p.disconnected and not SamePlayer(name,LocalKey()) then one(name) end end
    end
end

local function SendMember(target,name,p)
    Send(target,"MEMBER",DM.state.sessionID,name,tostring(p.trp or ShortName(name)):sub(1,60),p.currentHP or "",p.maxHP or "","0",p.isHost and "1" or "0",p.disconnected and "1" or "0")
end

local function SendStyle(target,name,p)
    if not target or not p then return end
    Send(target,"STYLE",DM.state.sessionID,name,tostring(p.theme or ""):sub(1,24),tostring(p.themeHex or ""):sub(1,8),
        tostring(p.portraitFrame or "Default"):sub(1,24),tostring(p.levelBadge or "Initiate Seal"):sub(1,24),tostring(p.level or 1))
end

local function BroadcastStyle(name,p,except)
    local state=DM.state;if not IsHost(state) or not p then return end
    for target,tp in pairs(state.participants or {}) do
        if not tp.disconnected and not SamePlayer(target,LocalKey()) and (not except or not SamePlayer(target,except)) then SendStyle(target,name,p) end
    end
end

function DM.RefreshLocalStyle()
    local state=DM.state;if not state then return false end
    local key=ParticipantKey(state,LocalKey()) or LocalKey();local p=state.participants and state.participants[key]
    if not p then return false end
    ApplyLocalStyle(p)
    if IsHost(state) then
        BroadcastStyle(key,p)
    elseif state.host then
        Send(state.host,"STYLE",state.sessionID,key,p.theme or "",p.themeHex or "",p.portraitFrame or "Default",p.levelBadge or "Initiate Seal",p.level or 1)
    end
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer(true) end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end
    if RPAL.RefreshDMRollUI then RPAL.RefreshDMRollUI() end
    return true,p
end

local function SendBeacon(target,marker,b)
    Send(target,"BEACON",DM.state.sessionID,marker,b.npcID or "",b.name or "NPC",b.currentHP or 0,b.maxHP or 1,b.title or "")
end

function DM.SyncTo(target)
    local state=DM.state;if not IsHost(state) or not ParticipantKey(state,target) then return false end
    DM.BroadcastSessionHeader(target)
    for name,p in pairs(state.participants or {}) do SendMember(target,name,p);SendStyle(target,name,p) end
    for marker,b in pairs(state.beacons or {}) do SendBeacon(target,marker,b) end
    for _,n in ipairs(state.notifications or {}) do
        if not n.target or SamePlayer(n.target,target) then Send(target,"NOTIFY",state.sessionID,n.id,n.t,n.title,n.body,n.target or "") end
    end
    for _,e in ipairs(state.log or {}) do
        if e.visibility=="PUBLIC" or (e.visibility=="TARGET_ONLY" and SamePlayer(e.target,target)) then SendPublicLogTo(target,e) end
    end
    -- V2 roll state is synchronized by the DM.SyncTo wrapper below.
    return true
end

function DM.GetCampaignHistory(campaignID)
    local c=DM.GetCampaign(campaignID);if not c then return {} end
    c.sessionHistory=c.sessionHistory or {}
    return c.sessionHistory
end

local function ArchiveHostSession(state)
    if not state or not IsHost(state) or state._campaignSegmentArchived then return end
    local c=DM.GetCampaign(state.campaignID);if not c then return end
    c.sessionHistory=c.sessionHistory or {}
    local copy={sessionID=state.sessionID,startedAt=state.segmentStartedAt or state.resumedAt or state.startedAt,endedAt=time(),campaignName=state.campaignName,log={}}
    state._campaignSegmentArchived=true
    for _,e in ipairs(state.log or {}) do
        copy.log[#copy.log+1]={id=e.id,t=e.t,text=e.text,visibility=e.visibility,target=e.target,kind=e.kind}
    end
    c.sessionHistory[#c.sessionHistory+1]=copy
    local stats=CampaignStats(c);if stats then
        stats.sessionCount=(tonumber(stats.sessionCount) or 0)+1
        stats.activeDuration=(tonumber(stats.activeDuration) or 0)+math.max(0,(tonumber(copy.endedAt) or 0)-(tonumber(copy.startedAt) or 0))
    end
    while #c.sessionHistory>30 do table.remove(c.sessionHistory,1) end
end

function DM.StartSession(campaignID)
    if not DM.IsEnabled() then return false,"Enable DM Kit in Settings first." end
    if DM.state then return false,"A DM session is already active." end
    local campaign=DM.GetCampaign(campaignID) or DM.GetActiveCampaign()
    if not campaign then return false,"Create a campaign first." end
    if campaign.concluded then return false,"This Campaign has already been concluded." end
    campaign.campaignStartedAt=campaign.campaignStartedAt or time()
    campaign.lastSessionAt=time()
    Data().activeCampaignID=campaign.id
    local me=LocalKey()
    DM.state={
        sessionID=NewID("dmsession"),campaignID=campaign.id,campaignName=campaign.name,host=me,role="HOST",startedAt=time(),segmentStartedAt=time(),
        participants={},beacons={},notifications={},log={},pendingDamage={},allowPlayerDamage=false,playerHealthVisible=true,roll=nil,initiative=nil,pendingInitiative=nil,healthSetupMax=nil,
    }
    -- Host is tracked for identity and whisper/roster purposes but is not a default roll target.
    DM.state.participants[me]={trp=LocalTRPName(),joinedAt=time(),isHost=true,currentHP=nil,maxHP=nil}
    ApplyLocalStyle(DM.state.participants[me])
    TrackCampaignParticipant(campaign,me,DM.state.participants[me].trp,true)
    LogLocal(DM.state,"Live DM session started for "..campaign.name..".","HOST_ONLY",nil,"SESSION")
    if RPAL.RefreshDMKitAvailability then RPAL.RefreshDMKitAvailability() end
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true,DM.state
end

function DM.EndSession()
    local state=DM.state
    if not state then return false,"No DM session is active." end
    if IsHost(state) then
        LogLocal(state,"Live DM session ended.","HOST_ONLY",nil,"SESSION")
        ArchiveHostSession(state)
        Broadcast("END")
    end
    DM.state=nil
    DM._incomingInvite=nil
    if RPAL.TeardownDMSessionUI then RPAL.TeardownDMSessionUI() end
    if RPAL.CloseDMRollUI then RPAL.CloseDMRollUI(true) end
    if RPAL.RefreshDMKitAvailability then RPAL.RefreshDMKitAvailability() end
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
    return true
end

function DM.InvitePlayer(name)
    local state,err=EnsureHostState();if not state then return false,err end
    name=Trim(name)
    if name=="" then return false,"No player selected." end
    if SamePlayer(name,LocalKey()) then return false,"You are already the host." end
    local existingKey=ParticipantKey(state,name)
    if existingKey and not (state.participants[existingKey] and state.participants[existingKey].disconnected) then return false,"That player is already in the session." end
    if not existingKey and ParticipantCount(state)>=MAX_PARTICIPANTS then return false,"The DM session is full." end
    Send(name,"INVITE",state.sessionID,state.campaignID,state.campaignName,state.host,LocalTRPName())
    LogLocal(state,"Invitation sent to "..ShortName(name)..".","HOST_ONLY",nil,"PLAYER")
    return true
end

function DM.InviteUnit(unit)
    local name=FullName(unit)
    if not name then return false,"No valid player." end
    return DM.InvitePlayer(name)
end

function DM.InviteGroup()
    local state,err=EnsureHostState();if not state then return false,err end
    local invited=0
    if IsInRaid and IsInRaid() then
        for i=1,40 do if UnitExists("raid"..i) and not UnitIsUnit("raid"..i,"player") then local ok=DM.InviteUnit("raid"..i);if ok then invited=invited+1 end end end
    else
        for i=1,4 do if UnitExists("party"..i) then local ok=DM.InviteUnit("party"..i);if ok then invited=invited+1 end end end
    end
    return true,invited
end

function DM.AcceptInvite()
    local inv=DM._incomingInvite
    if not inv then return false,"No pending DM invite." end
    if DM.state then return false,"You are already in a DM session." end
    Send(inv.host,"JOIN",inv.sessionID,LocalKey(),LocalTRPName())
    local style=LocalDMStyle()
    Send(inv.host,"STYLE",inv.sessionID,LocalKey(),style.theme,style.themeHex,style.portraitFrame,style.levelBadge,style.level)
    DM._incomingInvite=nil
    if RPAL.HideDMInvite then RPAL.HideDMInvite() end
    return true
end

function DM.DeclineInvite()
    local inv=DM._incomingInvite
    if not inv then return false end
    Send(inv.host,"DECLINE",inv.sessionID,LocalKey())
    DM._incomingInvite=nil
    if RPAL.HideDMInvite then RPAL.HideDMInvite() end
    return true
end

function DM.RemoveParticipant(name)
    local state,err=EnsureHostState();if not state then return false,err end
    if state.roll and not state.roll.complete then return false,"Finish the active roll check before changing the roster." end
    local key=ParticipantKey(state,name)
    if not key or SamePlayer(key,LocalKey()) then return false,"Player not found." end
    local p=state.participants[key]
    state.participants[key]=nil
    Send(key,"KICK",state.sessionID,"Removed by the DM.")
    PublicLog((p.trp or ShortName(key)).." left the DM session.","PLAYER")
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    return true
end

function DM.LeaveSession()
    local state=DM.state
    if not state then return false end
    if IsHost(state) then return DM.EndSession() end
    Send(state.host,"LEAVE",state.sessionID,LocalKey())
    DM.state=nil
    if RPAL.CloseDMRollUI then RPAL.CloseDMRollUI(true) end
    if RPAL.RefreshDMKitAvailability then RPAL.RefreshDMKitAvailability() end
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
    return true
end

function DM.SetAllowPlayerDamage(enabled)
    local state,err=EnsureHostState();if not state then return false,err end
    state.allowPlayerDamage=enabled and true or false
    DM.BroadcastSessionHeader()
    PublicLog("Player damage input was "..(state.allowPlayerDamage and "enabled" or "disabled")..".","SYSTEM")
    if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
    return true
end

function DM.SetPlayerHealthVisibility(enabled)
    local state,err=EnsureHostState();if not state then return false,err end
    state.playerHealthVisible=enabled and true or false
    DM.BroadcastSessionHeader()
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    return true
end

function DM.RequestPlayerHealth(maxHP,target)
    local state,err=EnsureHostState();if not state then return false,err end
    maxHP=ClampInt(maxHP,1,1000000);if not maxHP then return false,"Invalid maximum health." end
    state.healthSetupMax=maxHP
    local sent=0
    for name,p in pairs(state.participants or {}) do
        if not p.isHost and not p.disconnected and (not target or SamePlayer(name,target)) then
            Send(name,"HEALTH_REQ",state.sessionID,maxHP);sent=sent+1
        end
    end
    if sent==0 then return false,"No players to request health from." end
    LogLocal(state,"Requested player health values with a maximum of "..maxHP..".","HOST_ONLY",nil,"HEALTH")
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    return true,sent
end

local function BroadcastPlayerHP(name,p)
    local state=DM.state;if not IsHost(state) then return end
    Broadcast("PLAYER_HP",name,p.currentHP or "",p.maxHP or "")
end

function DM.SetPlayerHP(name,currentHP,maxHP,reason)
    local state,err=EnsureHostState();if not state then return false,err end
    local key=ParticipantKey(state,name);local p=key and state.participants[key]
    if not p then return false,"Player not found." end
    local max=ClampInt(maxHP or p.maxHP or currentHP,1,1000000);if not max then return false,"Invalid maximum health." end
    local current=ClampInt(currentHP,0,max);if not current then return false,"Invalid current health." end
    local old=p.currentHP
    p.maxHP=max;p.currentHP=current
    BroadcastPlayerHP(key,p)
    PublicLog((p.trp or ShortName(key)).." health: "..tostring(old or "—").." → "..current.." / "..max..(reason and (" ("..reason..")") or "")..".","HEALTH")
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    return true
end

function DM.ApplyPlayerDelta(name,amount,isHealing)
    local state,err=EnsureHostState();if not state then return false,err end
    local key=ParticipantKey(state,name);local p=key and state.participants[key]
    if not p or not p.maxHP or p.currentHP==nil then return false,"Player health is not configured." end
    amount=ClampInt(amount,0,1000000);if not amount then return false,"Invalid amount." end
    local new=isHealing and math.min(p.maxHP,p.currentHP+amount) or math.max(0,p.currentHP-amount)
    return DM.SetPlayerHP(key,new,p.maxHP,(isHealing and ("+"..amount.." healing") or (amount.." damage")))
end

local MARKER_NAMES={[1]="Star",[2]="Circle",[3]="Diamond",[4]="Triangle",[5]="Moon",[6]="Square",[7]="Cross",[8]="Skull"}
DM.MARKER_NAMES=MARKER_NAMES

function DM.AssignNPCToMarker(npcID,marker)
    local state,err=EnsureHostState();if not state then return false,err end
    marker=ClampInt(marker,1,8);if not marker then return false,"Invalid raid beacon." end
    local c=DM.GetCampaign(state.campaignID);local npc=c and c.npcs and c.npcs[npcID]
    if not npc then return false,"NPC not found in the active campaign." end
    local b={npcID=npc.id,name=npc.name,title=npc.title,currentHP=npc.maxHP,maxHP=npc.maxHP,marker=marker}
    state.beacons[marker]=b
    TrackCampaignNPCInteraction(c,npc.id)
    Broadcast("BEACON",marker,b.npcID,b.name,b.currentHP,b.maxHP,b.title or "")
    PublicLog(MARKER_NAMES[marker].." beacon assigned to "..b.name.." ("..b.currentHP.." / "..b.maxHP..").","NPC")
    if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    return true,b
end

function DM.SyncNPCDefinition(npcID)
    local state,err=EnsureHostState();if not state then return false,err end
    local c=DM.GetCampaign(state.campaignID);local npc=c and c.npcs and c.npcs[npcID]
    if not npc then return false,"NPC not found." end
    local changed=0
    for marker,b in pairs(state.beacons or {}) do
        if b and b.npcID==npcID then
            b.name=npc.name;b.title=npc.title or "";b.maxHP=npc.maxHP or b.maxHP or 1;b.currentHP=math.min(tonumber(b.currentHP) or b.maxHP,b.maxHP)
            Broadcast("BEACON",marker,b.npcID,b.name,b.currentHP,b.maxHP,b.title or "");changed=changed+1
        end
    end
    if changed>0 then
        if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        PublicLog(npc.name.." NPC file updated for "..changed.." deployed beacon"..(changed==1 and "." or "s."),"NPC")
    end
    return true,changed
end

function DM.ReassignMarker(oldMarker,newMarker)
    local state,err=EnsureHostState();if not state then return false,err end
    oldMarker=ClampInt(oldMarker,1,8);newMarker=ClampInt(newMarker,1,8)
    if not oldMarker or not newMarker then return false,"Invalid raid beacon." end
    if oldMarker==newMarker then return true,state.beacons[oldMarker] end
    local b=state.beacons[oldMarker];if not b then return false,"No NPC is assigned to that beacon." end
    if state.beacons[newMarker] then return false,(MARKER_NAMES[newMarker] or "That").." beacon is already assigned." end
    state.beacons[oldMarker]=nil;b.marker=newMarker;state.beacons[newMarker]=b
    Broadcast("BEACON_CLEAR",oldMarker);Broadcast("BEACON",newMarker,b.npcID or "",b.name or "NPC",b.currentHP or 0,b.maxHP or 1,b.title or "")
    -- Keep an active Combat timeline attached to the same NPC when its world
    -- marker changes instead of silently breaking the initiative entry.
    local init=state.initiative
    if init and init.entries then for _,e in ipairs(init.entries) do if e.kind=="NPC" and tonumber(e.marker)==oldMarker then e.marker=newMarker;e.id="N:"..newMarker end end;DM.BroadcastInitiative() end
    PublicLog((b.name or "NPC").." moved from "..(MARKER_NAMES[oldMarker] or "beacon").." to "..(MARKER_NAMES[newMarker] or "beacon")..".","NPC")
    if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end;if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer(true) end;if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end
    return true,b
end

function DM.ClearMarker(marker)
    local state,err=EnsureHostState();if not state then return false,err end
    marker=ClampInt(marker,1,8);if not marker then return false,"Invalid raid beacon." end
    local old=state.beacons[marker]
    state.beacons[marker]=nil
    Broadcast("BEACON_CLEAR",marker)
    if old then PublicLog(MARKER_NAMES[marker].." beacon cleared from "..old.name..".","NPC") end
    if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    return true
end

function DM.SetNPCHealth(marker,currentHP,maxHP,reason,actor)
    local state,err=EnsureHostState();if not state then return false,err end
    marker=ClampInt(marker,1,8);local b=marker and state.beacons[marker]
    if not b then return false,"No NPC is assigned to that beacon." end
    local max=ClampInt(maxHP or b.maxHP,1,1000000);if not max then return false,"Invalid maximum health." end
    local current=ClampInt(currentHP,0,max);if not current then return false,"Invalid current health." end
    local old=b.currentHP
    b.maxHP=max;b.currentHP=current
    local campaign=DM.GetCampaign(state.campaignID)
    TrackCampaignNPCInteraction(campaign,b.npcID)
    if current<=0 and (tonumber(old) or 1)>0 then TrackCampaignNPCOutcome(campaign,b.npcID,"KILLED") end
    Broadcast("BEACON",marker,b.npcID,b.name,b.currentHP,b.maxHP,b.title or "")
    local prefix=actor and (actor.." dealt ") or (b.name.." health changed by ")
    if reason then
        PublicLog((actor and (actor.." → "..b.name..": ") or (b.name..": "))..reason..". "..tostring(old).." → "..current.." / "..max..".","NPC")
    else
        PublicLog(b.name.." health: "..tostring(old).." → "..current.." / "..max..".","NPC")
    end
    if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end
    return true
end

function DM.MarkNPCSaved(marker)
    local state,err=EnsureHostState();if not state then return false,err end
    marker=ClampInt(marker,1,8);local b=marker and state.beacons[marker]
    if not b then return false,"No NPC is assigned to that beacon." end
    local c=DM.GetCampaign(state.campaignID);TrackCampaignNPCInteraction(c,b.npcID);TrackCampaignNPCOutcome(c,b.npcID,"SAVED")
    PublicLog((b.name or "NPC").." was marked as saved.","NPC")
    return true
end

function DM.ApplyNPCDelta(marker,amount,isHealing,actor)
    local state,err=EnsureHostState();if not state then return false,err end
    marker=ClampInt(marker,1,8);local b=marker and state.beacons[marker]
    if not b then return false,"No NPC is assigned to that beacon." end
    amount=ClampInt(amount,0,1000000);if not amount then return false,"Invalid amount." end
    local new=isHealing and math.min(b.maxHP,b.currentHP+amount) or math.max(0,b.currentHP-amount)
    local reason=isHealing and ("healed "..amount) or ("took "..amount.." damage")
    return DM.SetNPCHealth(marker,new,b.maxHP,reason,actor)
end

function DM.SubmitDamage(marker,amount)
    local state=DM.state
    if not state or state.role~="PARTICIPANT" then return false,"Join a DM session first." end
    if not state.allowPlayerDamage then return false,"The DM has disabled player damage input." end
    marker=ClampInt(marker,1,8);amount=ClampInt(amount,1,1000000)
    if not marker or not state.beacons[marker] then return false,"NPC not found." end
    if not amount then return false,"Invalid damage amount." end
    local id=NewID("damage")
    Send(state.host,"DMG_REQ",state.sessionID,id,marker,amount)
    return true
end

function DM.GetPendingDamage()
    local state=DM.state;local result={}
    if not IsHost(state) then return result end
    for _,r in pairs(state.pendingDamage or {}) do result[#result+1]=r end
    table.sort(result,function(a,b)return (a.t or 0)<(b.t or 0) end)
    return result
end

function DM.ApproveDamage(requestID,editedAmount)
    local state,err=EnsureHostState();if not state then return false,err end
    local r=state.pendingDamage and state.pendingDamage[requestID]
    if not r then return false,"Damage request not found." end
    local amount=ClampInt(editedAmount or r.amount,1,1000000);if not amount then return false,"Invalid damage amount." end
    local p=state.participants[r.player]
    local actor=(p and p.trp) or ShortName(r.player)
    local ok,msg=DM.ApplyNPCDelta(r.marker,amount,false,actor)
    if ok then
        state.pendingDamage[requestID]=nil
        Send(r.player,"DMG_RES",state.sessionID,requestID,"APPROVED",amount)
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    end
    return ok,msg
end

function DM.RejectDamage(requestID)
    local state,err=EnsureHostState();if not state then return false,err end
    local r=state.pendingDamage and state.pendingDamage[requestID]
    if not r then return false,"Damage request not found." end
    state.pendingDamage[requestID]=nil
    local p=state.participants[r.player]
    Send(r.player,"DMG_RES",state.sessionID,requestID,"REJECTED",r.amount)
    LogLocal(state,"Rejected "..tostring(r.amount).." damage from "..((p and p.trp) or ShortName(r.player))..".","HOST_ONLY",nil,"NPC")
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    return true
end

function DM.SendNotification(title,body,target)
    local state,err=EnsureHostState();if not state then return false,err end
    title=Trim(title);body=Trim(body)
    if title=="" then return false,"Enter a notification title." end
    title=title:sub(1,56);body=body:sub(1,100)
    local id=NewID("notice");local now=time()
    AddNotificationLocal(state,id,now,title,body,target)
    if target then
        local key=ParticipantKey(state,target);local p=key and state.participants[key]
        if not p then return false,"Player not found." end
        Send(key,"NOTIFY",state.sessionID,id,now,title,body,key)
        PublicLog("DM notification: "..title,"NOTIFY",key)
    else
        Broadcast("NOTIFY",id,now,title,body,"")
        PublicLog("DM notification: "..title,"NOTIFY")
    end
    if RPAL.ShowDMNotification then RPAL.ShowDMNotification(title,body) end
    return true
end

function DM.DirectWhisper(name)
    local state=DM.state
    local key=ParticipantKey(state,name) or name
    if not key then return false end
    local participantKey=state and ParticipantKey(state,key)
    local participant=participantKey and state.participants[participantKey]
    local whisper=tostring(participantKey or key)
    if ChatFrame_OpenChat then
        ChatFrame_OpenChat("/w "..whisper.." ")
        if IsHost(state) then
            LogLocal(state,"Direct Whisper opened for "..((participant and participant.trp) or ShortName(whisper))..".","HOST_ONLY",nil,"PLAYER")
        end
        return true
    end
    return false
end

local function RollTargets(state)
    local result={}
    for name,p in pairs(state and state.participants or {}) do if not p.isHost then result[#result+1]=name end end
    table.sort(result,function(a,b)
        local pa,pb=state.participants[a],state.participants[b]
        if (pa.joinedAt or 0)==(pb.joinedAt or 0) then return tostring(a)<tostring(b) end
        return (pa.joinedAt or 0)<(pb.joinedAt or 0)
    end)
    return result
end

local function RollIsComplete(state)
    local r=state and state.roll;if not r then return false end
    for _,name in ipairs(r.targets or RollTargets(state)) do
        if r.results[name]==nil and not r.skipped[name] then return false end
    end
    return true
end

local function FinishRollIfReady(state)
    if not IsHost(state) or not state.roll or state.roll.complete or not RollIsComplete(state) then return false end
    state.roll.complete=true
    Broadcast("ROLL_DONE",state.roll.id)
    PublicLog("Roll check completed: "..state.roll.title..".","ROLL")
    if RPAL.RefreshDMRollUI then RPAL.RefreshDMRollUI() end
    return true
end

local function IsRollTarget(r,name)
    if not r or not name then return false end
    for _,target in ipairs(r.targets or {}) do if SamePlayer(target,name) then return true end end
    return false
end

local function ConnectedTargets(state)
    local out={}
    for name,p in pairs(state and state.participants or {}) do
        if not p.disconnected and not SamePlayer(name,LocalKey()) then out[#out+1]=name end
    end
    return out
end

local function SendToRollTargets(kind,...)
    local state=DM.state;local r=state and state.roll
    if not IsHost(state) or not r then return end
    for _,name in ipairs(r.targets or {}) do
        local p=state.participants and state.participants[name]
        if p and not p.disconnected and not SamePlayer(name,LocalKey()) then Send(name,kind,state.sessionID,...) end
    end
end

local function SortedParticipantKeys(state)
    local keys={};for name in pairs(state and state.participants or {}) do keys[#keys+1]=name end
    table.sort(keys,function(a,b)return NormalizeName(a)<NormalizeName(b) end);return keys
end

local function RollTargetCSV(targets,state)
    state=state or DM.state;local keys=SortedParticipantKeys(state);local chosen={}
    for _,name in ipairs(targets or {}) do chosen[NormalizeName(name)]=true end
    local bits={};for _,name in ipairs(keys) do bits[#bits+1]=chosen[NormalizeName(name)] and "1" or "0" end
    return "#"..table.concat(bits)
end

local function NormalizeRollTargets(state,targets)
    local out,seen={},{}
    if type(targets)~="table" or #targets==0 then return RollTargets(state) end
    for _,name in ipairs(targets) do
        local key=ParticipantKey(state,name)
        local p=key and state.participants[key]
        if key and p and not p.disconnected then
            local norm=NormalizeName(key)
            if not seen[norm] then seen[norm]=true;out[#out+1]=key end
        end
    end
    table.sort(out,function(a,b)
        local pa,pb=state.participants[a] or {},state.participants[b] or {}
        if (pa.joinedAt or 0)==(pb.joinedAt or 0) then return tostring(a)<tostring(b) end
        return (pa.joinedAt or 0)<(pb.joinedAt or 0)
    end)
    return out
end

-- Human-readable roll logging.  The host keeps the full context while only the
-- audience of a targeted check receives its public-facing log lines.
local function RollDisplayName(state,name)
    local p=state and state.participants and state.participants[name]
    return tostring((p and p.trp) or ShortName(name) or "Player")
end

local function RollTargetNames(state,r)
    local out={}
    for _,name in ipairs(r and r.targets or {}) do out[#out+1]=RollDisplayName(state,name) end
    return out
end

local function SendRollAudienceLog(state,r,text,kind)
    if not IsHost(state) or not r then return end
    local targets={}
    if r.purpose=="TURN" then
        -- The turn roller gets the modal, but the whole live party should be
        -- able to follow the combat result in Session Log.
        for name,p in pairs(state.participants or {}) do if not p.disconnected then targets[#targets+1]=name end end
    else
        for _,name in ipairs(r.targets or {}) do targets[#targets+1]=name end
    end
    local sent={}
    for _,name in ipairs(targets) do
        local p=state.participants and state.participants[name];local key=NormalizeName(name)
        if p and not p.disconnected and not sent[key] and not SamePlayer(name,LocalKey()) then
            sent[key]=true
            local entry={id=NewID("log"),t=time(),kind=kind or "ROLL",text=tostring(text or ""):sub(1,140)}
            SendPublicLogTo(name,entry)
        end
    end
end

local function RollOutcomeText(r,value)
    if not r or not r.minimum then return "" end
    return value>=r.minimum and " · SUCCESS" or " · FAILED"
end

local function LogRollStarted(state,r)
    if not state or not r then return end
    local targetNames=RollTargetNames(state,r)
    local dice="D"..tostring(r.sides or 20)
    local statText=r.stat and (" · "..tostring(ROLL_STATS[r.stat] or r.stat).." ("..r.stat..")") or ""
    local dc=r.minimum and (" · DC "..tostring(r.minimum)) or ""
    local reason=Trim(r.info)
    local hostText
    if r.purpose=="TURN" then
        hostText="Combat turn roll requested — "..tostring(targetNames[1] or "Player").." · Round "..tostring(r.round or (state.initiative and state.initiative.round) or 1).." · "..dice.."."
    else
        hostText="Roll Check — "..tostring(r.title or "Check").."\nDice: "..dice..statText..dc.."\nTargets: "..(#targetNames>0 and table.concat(targetNames,", ") or "None")
        if reason~="" then hostText=hostText.."\nReason: "..reason end
    end
    LogLocal(state,hostText,"HOST_ONLY",nil,"ROLL")
    local audience
    if r.purpose=="TURN" then
        audience="Combat Turn · Round "..tostring(r.round or 1).." · "..tostring(targetNames[1] or "Player").." · "..dice
    else
        audience="Roll Check · "..tostring(r.title or "Check").." · "..dice..statText..dc
        if reason~="" then audience=audience.." · "..reason end
    end
    SendRollAudienceLog(state,r,audience,"ROLL")
end

local function LogRollResult(state,r,player,value)
    if not state or not r or not player or value==nil then return end
    local name=RollDisplayName(state,player)
    local raw=(r.rawResults and r.rawResults[player]) or value
    local modifier=(r.modifiers and r.modifiers[player]) or 0
    local maxMark=(tonumber(raw)==tonumber(r.sides)) and "!" or ""
    local formula=r.stat and (" ("..tostring(raw)..(modifier>=0 and "+" or "")..tostring(modifier)..")") or ""
    local result=tostring(value)..maxMark..formula..RollOutcomeText(r,tonumber(value) or 0)
    local text
    if r.purpose=="TURN" then
        text="Combat Turn · Round "..tostring(r.round or 1).." · "..name.." rolled "..result.."."
    elseif r.purpose=="INITIATIVE" then
        text="Initiative · "..name.." rolled "..result.."."
    else
        text=tostring(r.title or "Roll Check").." · "..name.." rolled "..result.."."
    end
    LogLocal(state,text,"HOST_ONLY",nil,"ROLL")
    SendRollAudienceLog(state,r,text,"ROLL")
end

local function LogRollCompleted(state,r)
    if not state or not r then return end
    local bits={}
    for _,name in ipairs(r.targets or {}) do
        local label=RollDisplayName(state,name)
        if r.skipped and r.skipped[name] then
            bits[#bits+1]=label.." skipped"
        elseif r.results and r.results[name]~=nil then
            local value=r.results[name]
            local raw=(r.rawResults and r.rawResults[name]) or value
            local mark=(tonumber(raw)==tonumber(r.sides)) and "!" or ""
            local outcome=r.minimum and (value>=r.minimum and " ✓" or " ✕") or ""
            bits[#bits+1]=label.." "..tostring(value)..mark..outcome
        end
    end
    if r.purpose=="TURN" then return end -- the single turn-result line is already the useful summary.
    local text="Roll Check complete — "..tostring(r.title or "Check")
    if #bits>0 then text=text.." · "..table.concat(bits," · ") end
    LogLocal(state,text,"HOST_ONLY",nil,"ROLL")
    SendRollAudienceLog(state,r,text,"ROLL")
end

local StartCurrentTurnRoll
local ClearRollForTurnTransition

-- Override roll completion so targeted checks only message their audience and
-- initiative can be built automatically from the completed initiative check.
local function BuildInitiativeFromPending(state)
    local pending=state and state.pendingInitiative;local r=state and state.roll
    if not pending or not r or r.purpose~="INITIATIVE" then return false end
    local entries={}
    for _,name in ipairs(r.targets or {}) do
        local p=state.participants[name]
        if p and r.results[name]~=nil then
            entries[#entries+1]={id="P:"..name,kind="PLAYER",player=name,name=p.trp or ShortName(name),value=r.results[name]}
        end
    end
    for marker,value in pairs(pending.npcResults or {}) do
        local b=state.beacons[marker]
        if b then entries[#entries+1]={id="N:"..marker,kind="NPC",marker=marker,name=b.name,title=b.title,value=value} end
    end
    table.sort(entries,function(a,b)
        if (a.value or 0)==(b.value or 0) then return tostring(a.id)<tostring(b.id) end
        return (a.value or 0)>(b.value or 0)
    end)
    state.initiative={active=true,entries=entries,index=1,hideOrder=pending.hideOrder and true or false,startedAt=time(),round=1}
    state.pendingInitiative=nil
    if #entries>0 then
        PublicLog("Initiative started. First turn: "..tostring(entries[1].name)..".","INITIATIVE")
    else
        PublicLog("Initiative started with no valid entries.","INITIATIVE")
    end
    if DM.BroadcastInitiative then DM.BroadcastInitiative() end
    if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    -- Let the final initiative dice animation finish before replacing it with
    -- the first combat-turn roll prompt.
    if C_Timer and C_Timer.After then C_Timer.After(.75,function()if DM.state==state then StartCurrentTurnRoll(state) end end) else StartCurrentTurnRoll(state) end
    return true
end

FinishRollIfReady=function(state)
    if not IsHost(state) or not state.roll or state.roll.complete or not RollIsComplete(state) then return false end
    local finished=state.roll
    finished.complete=true
    SendToRollTargets("ROLL_DONE",finished.id)
    LogRollCompleted(state,finished)
    if finished.purpose=="INITIATIVE" then BuildInitiativeFromPending(state) end
    if RPAL.RefreshDMRollUI then RPAL.RefreshDMRollUI() end
    return true
end

-- The original roll handlers were declared before the V2 logging helpers.
-- In Lua that made their LogRollResult / SendRollAudienceLog references global
-- (and therefore nil), so a host rolling for themselves could stop at the button click.
-- Bind the live handlers here, after every helper
-- is in lexical scope, and report transport failures instead of silently
-- pretending a participant roll was sent.
function DM.RollLocal()
    local state=DM.state;local r=state and state.roll
    if not state or not r or r.complete then return false,"No active roll check." end
    local me=ParticipantKey(state,LocalKey())
    if not me then return false,"You are not part of this roll check." end
    if not IsRollTarget(r,me) then return false,"You are observing this roll check." end
    if r.results[me]~=nil or r.skipped[me] then return false,"You already rolled." end
    local raw=math.random(1,r.sides)
    local modifier=LocalRollStatModifier(r.stat)
    local value=raw+modifier
    if state.role=="PARTICIPANT" then
        if not Send(state.host,"ROLL",state.sessionID,r.id,me,raw,modifier) then
            return false,"Could not send the roll to the DM. Check that both players are online and using this ODYN version."
        end
    elseif IsHost(state) then
        r.rawResults=r.rawResults or {};r.modifiers=r.modifiers or {}
        r.results[me]=value;r.rawResults[me]=raw;r.modifiers[me]=modifier
        TrackCampaignRoll(DM.GetCampaign(state.campaignID),value)
        SendToRollTargets("ROLL_RESULT",r.id,me,value,raw,modifier)
        LogRollResult(state,r,me,value)
        FinishRollIfReady(state)
    else
        return false,"You are not part of this roll check."
    end
    if RPAL.RefreshDMRollUI then RPAL.RefreshDMRollUI() end
    return true,value
end

function DM.SkipRollPlayer(name)
    local state,err=EnsureHostState();if not state then return false,err end
    local r=state.roll;if not r or r.complete then return false,"No active roll check." end
    local key=ParticipantKey(state,name);if not key then return false,"Player not found." end
    if not IsRollTarget(r,key) then return false,"That player is not targeted by this check." end
    if r.results[key]~=nil then return false,"That player already rolled." end
    r.skipped[key]=true
    SendToRollTargets("ROLL_SKIP",r.id,key)
    local participant=state.participants[key]
    local skipText=tostring((participant and participant.trp) or ShortName(key)).." was skipped for "..tostring(r.title or "Roll Check").."."
    LogLocal(state,skipText,"HOST_ONLY",nil,"ROLL")
    SendRollAudienceLog(state,r,skipText,"ROLL")
    FinishRollIfReady(state)
    if RPAL.RefreshDMRollUI then RPAL.RefreshDMRollUI() end
    return true
end

function DM.StartRollCheck(title,info,sides,minimum,targets,options)
    local state,err=EnsureHostState();if not state then return false,err end
    if state.roll and not state.roll.complete then return false,"Finish the current roll check first." end
    title=Trim(title);info=Trim(info)
    if title=="" then return false,"Enter a roll check title." end
    sides=ClampInt(sides,1,100);if not sides or not VALID_DICE[sides] then return false,"Choose D4, D6, D8, D12, D20 or D100." end
    options=type(options)=="table" and options or {}
    local stat=NormalizeRollStat(options.stat)
    local maxMinimum=sides+(stat and 20 or 0)
    if minimum~=nil and Trim(minimum)~="" then minimum=ClampInt(minimum,1,maxMinimum);if not minimum then return false,"Minimum must be between 1 and "..tostring(maxMinimum).."." end else minimum=nil end
    local resolved=NormalizeRollTargets(state,targets)
    if #resolved==0 then return false,"Select at least one connected participant." end
    local r={id=NewID("roll"),title=title:sub(1,56),info=info:sub(1,90),sides=sides,minimum=minimum,results={},rawResults={},modifiers={},skipped={},targets=resolved,startedAt=time(),complete=false,purpose=options.purpose or "CHECK",round=ClampInt(options.round,1,10000),stat=stat}
    state.roll=r
    local csv=RollTargetCSV(resolved,state)
    for _,name in ipairs(resolved) do
        local p=state.participants[name]
        if p and not p.disconnected and not SamePlayer(name,LocalKey()) then
            Send(name,"ROLL_START",state.sessionID,r.id,r.title,r.info,r.sides,r.minimum or "",csv,r.purpose,r.stat or "")
        end
    end
    LogRollStarted(state,r)
    if IsRollTarget(r,LocalKey()) and RPAL.OpenDMRollUI then RPAL.OpenDMRollUI() elseif RPAL.OpenDMRollUI then RPAL.OpenDMRollUI() end
    if RPAL.RefreshDMRollUI then RPAL.RefreshDMRollUI() end
    return true,r
end

ClearRollForTurnTransition=function(state)
    if not state or not IsHost(state) then return end
    local r=state.roll
    if not r then return end
    for _,name in ipairs(r.targets or {}) do
        local p=state.participants and state.participants[name]
        if p and not p.disconnected and not SamePlayer(name,LocalKey()) then Send(name,"ROLL_CLEAR",state.sessionID,r.id) end
    end
    state.roll=nil
    if RPAL.CloseDMRollUI then RPAL.CloseDMRollUI(true) end
end

StartCurrentTurnRoll=function(state)
    state=state or DM.state
    if not IsHost(state) or state~=DM.state then return false end
    local init=state.initiative
    if not init or not init.active or #(init.entries or {})==0 then return false end
    local current=init.entries[init.index or 1]
    -- A live generic check takes precedence. A completed check can safely be
    -- archived before the next combat-turn roll is opened.
    if state.roll and not state.roll.complete then return false end
    if state.roll then ClearRollForTurnTransition(state) end
    if not current or current.kind~="PLAYER" or not current.player then return false end
    local participant=state.participants and state.participants[current.player]
    if not participant or participant.disconnected then return false end
    local display=participant.trp or current.name or ShortName(current.player)
    return DM.StartRollCheck("Turn Roll — "..tostring(display),"Round "..tostring(init.round or 1).." · Roll for your combat turn.",20,nil,{current.player},{purpose="TURN",round=init.round or 1})
end

function DM.GetRollEligiblePlayers(includeHost)
    local state=DM.state;local out={}
    for name,p in pairs(state and state.participants or {}) do
        if not p.disconnected and (includeHost or not p.isHost) then out[#out+1]={name=name,p=p} end
    end
    table.sort(out,function(a,b)return (a.p.joinedAt or 0)<(b.p.joinedAt or 0) end)
    return out
end

-- ---------------------- Initiative / Turn Tracker ----------------------
function DM.GetInitiative() return DM.state and DM.state.initiative end

local function SendInitiativeTo(target)
    local state=DM.state;local init=state and state.initiative
    if not IsHost(state) or not init or not init.active then return end
    Send(target,"INIT_BEGIN",state.sessionID,init.index or 1,init.hideOrder and "1" or "0",init.round or 1,#(init.entries or {}),init.startedAt or time())
    for i,e in ipairs(init.entries or {}) do
        Send(target,"INIT_ENTRY",state.sessionID,i,e.id or "",e.kind or "",e.player or "",e.marker or "",e.name or "",e.title or "",e.value or 0)
    end
    Send(target,"INIT_CURRENT",state.sessionID,init.index or 1,init.round or 1)
end

function DM.BroadcastInitiative(target)
    local state=DM.state;if not IsHost(state) then return false end
    if target then SendInitiativeTo(target) else
        for name,p in pairs(state.participants or {}) do if not p.disconnected and not SamePlayer(name,LocalKey()) then SendInitiativeTo(name) end end
    end
    return true
end

function DM.StartInitiative(playerNames,npcMarkers,hideOrder)
    local state,err=EnsureHostState();if not state then return false,err end
    if state.roll and not state.roll.complete then return false,"Finish the current roll check first." end
    local players=NormalizeRollTargets(state,playerNames)
    local npcResults={};local npcCount=0
    for _,marker in ipairs(type(npcMarkers)=="table" and npcMarkers or {}) do
        marker=ClampInt(marker,1,8)
        if marker and state.beacons[marker] and npcResults[marker]==nil then npcResults[marker]=math.random(1,20);npcCount=npcCount+1 end
    end
    if #players==0 and npcCount==0 then return false,"Select at least one player or deployed NPC." end
    state.pendingInitiative={npcResults=npcResults,hideOrder=hideOrder and true or false,createdAt=time()}
    if #players==0 then
        state.roll={id=NewID("roll"),title="Initiative",info="Turn order",sides=20,results={},skipped={},targets={},startedAt=time(),complete=true,purpose="INITIATIVE"}
        BuildInitiativeFromPending(state)
        return true,state.initiative
    end
    return DM.StartRollCheck("Initiative","Roll for turn order.",20,nil,players,{purpose="INITIATIVE"})
end

function DM.StartInitiativeManual(playerNames,npcMarkers,hideOrder)
    local state,err=EnsureHostState();if not state then return false,err end
    if state.roll and not state.roll.complete then return false,"Finish the current roll check first." end
    local players=NormalizeRollTargets(state,playerNames);local entries={};local seenNPC={}
    for _,name in ipairs(players) do local p=state.participants and state.participants[name];if p then entries[#entries+1]={id="P:"..name,kind="PLAYER",player=name,name=p.trp or ShortName(name),title="",value=0} end end
    for _,marker in ipairs(type(npcMarkers)=="table" and npcMarkers or {}) do marker=ClampInt(marker,1,8);local b=marker and state.beacons[marker];if b and not seenNPC[marker] then seenNPC[marker]=true;entries[#entries+1]={id="N:"..marker,kind="NPC",marker=marker,name=b.name,title=b.title,value=0} end end
    if #entries==0 then return false,"Select at least one player or deployed NPC." end
    state.pendingInitiative=nil;state.initiative={active=true,entries=entries,index=1,hideOrder=hideOrder and true or false,startedAt=time(),round=1,manual=true}
    PublicLog("Combat started with a manually arranged turn order. First turn: "..tostring(entries[1] and entries[1].name or "Unknown")..".","INITIATIVE");DM.BroadcastInitiative()
    if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end;if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    if C_Timer and C_Timer.After then C_Timer.After(.12,function()if DM.state==state then StartCurrentTurnRoll(state) end end) else StartCurrentTurnRoll(state) end
    return true,state.initiative
end

function DM.MoveInitiativeEntry(fromIndex,toIndex)
    local state,err=EnsureHostState();if not state then return false,err end
    local init=state.initiative;if not init or not init.active then return false,"Initiative is not active." end
    fromIndex=ClampInt(fromIndex,1,#init.entries);toIndex=ClampInt(toIndex,1,#init.entries)
    if not fromIndex or not toIndex then return false,"Invalid turn position." end
    local currentID=init.entries[init.index] and init.entries[init.index].id
    local item=table.remove(init.entries,fromIndex);table.insert(init.entries,toIndex,item)
    for i,e in ipairs(init.entries) do if e.id==currentID then init.index=i break end end
    DM.BroadcastInitiative();if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end;if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    return true
end

function DM.AddNPCToInitiative(marker,initiativeValue)
    local state,err=EnsureHostState();if not state then return false,err end
    local init=state.initiative;if not init or not init.active then return false,"Combat is not active." end
    marker=ClampInt(marker,1,8);local b=marker and state.beacons and state.beacons[marker]
    if not b then return false,"That deployed NPC no longer exists." end
    for _,e in ipairs(init.entries or {}) do
        if e.kind=="NPC" and tonumber(e.marker)==marker then return false,"That NPC is already in Combat." end
    end
    local currentID=init.entries[init.index or 1] and init.entries[init.index or 1].id
    local value
    if init.manual then value=ClampInt(initiativeValue or 0,0,1000) or 0
    else value=ClampInt(initiativeValue,1,20) or math.random(1,20) end
    init.entries=init.entries or {}
    init.entries[#init.entries+1]={id="N:"..marker,kind="NPC",marker=marker,name=b.name,title=b.title or "",value=value}
    if not init.manual then
        table.sort(init.entries,function(a,c)
            local av,cv=tonumber(a.value) or 0,tonumber(c.value) or 0
            if av~=cv then return av>cv end
            return tostring(a.id or "")<tostring(c.id or "")
        end)
    end
    init.index=1
    if currentID then for i,e in ipairs(init.entries) do if e.id==currentID then init.index=i break end end end
    PublicLog((b.name or "NPC").." joined Combat"..(init.manual and "." or (" with initiative "..tostring(value)..".")),"INITIATIVE")
    DM.BroadcastInitiative()
    if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer(true) end
    return true,init.entries
end

function DM.AdvanceTurn(delta)
    local state,err=EnsureHostState();if not state then return false,err end
    local init=state.initiative;if not init or not init.active or #(init.entries or {})==0 then return false,"Initiative is not active." end
    if state.roll and not state.roll.complete then return false,"Finish or skip the active roll before changing turns." end
    if state.roll then ClearRollForTurnTransition(state) end
    delta=tonumber(delta) or 1
    local n=#init.entries;local old=init.index or 1;local new=((old-1+delta)%n)+1
    if delta>0 and new<=old then init.round=(init.round or 1)+1 elseif delta<0 and new>=old then init.round=math.max(1,(init.round or 1)-1) end
    init.index=new
    local e=init.entries[new]
    PublicLog("Turn: "..tostring(e and e.name or "Unknown").." (Round "..tostring(init.round or 1)..").","INITIATIVE")
    Broadcast("INIT_CURRENT",init.index,init.round or 1)
    if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end;if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    if C_Timer and C_Timer.After then C_Timer.After(.12,function()if DM.state==state then StartCurrentTurnRoll(state) end end) else StartCurrentTurnRoll(state) end
    return true,e
end

function DM.NextTurn() return DM.AdvanceTurn(1) end
function DM.PreviousTurn() return DM.AdvanceTurn(-1) end

function DM.EndInitiative()
    local state,err=EnsureHostState();if not state then return false,err end
    if not state.initiative then return false,"Initiative is not active." end
    if state.roll then ClearRollForTurnTransition(state) end
    state.initiative=nil;state.pendingInitiative=nil
    Broadcast("INIT_CLEAR")
    PublicLog("Combat ended.","INITIATIVE")
    if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end;if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    return true
end

-- ---------------------- Screen Messages ----------------------
function DM.SendScreenMessage(title,subtitle,duration,targets,preset)
    local state,err=EnsureHostState();if not state then return false,err end
    title=Trim(title);subtitle=Trim(subtitle);if title=="" then return false,"Enter a screen-message title." end
    duration=ClampInt(duration or 5,2,12) or 5;preset=tostring(preset or "CINEMATIC"):upper()
    if preset~="SUBTLE" and preset~="WARNING" then preset="CINEMATIC" end
    local style=LocalDMStyle();local resolved={}
    if type(targets)=="table" and #targets>0 then resolved=NormalizeRollTargets(state,targets) end
    local function deliver(name)
        local p=state.participants[name];if p and not p.disconnected and not SamePlayer(name,LocalKey()) then
            Send(name,"SCREEN",state.sessionID,title:sub(1,64),subtitle:sub(1,120),duration,preset,style.themeHex or "")
        end
    end
    if #resolved>0 then
        for _,name in ipairs(resolved) do deliver(name) end
        for _,name in ipairs(resolved) do PublicLog("Screen message: "..title,"SCREEN",name) end
    else
        for name in pairs(state.participants or {}) do deliver(name) end
        PublicLog("Screen message: "..title,"SCREEN")
    end
    if RPAL.ShowDMScreenMessage then RPAL.ShowDMScreenMessage(title,subtitle,duration,preset,style.themeHex) end
    return true
end

-- ---------------------- Encounter Templates ----------------------
local function EnsureTemplateData(c)
    c.encounterTemplates=c.encounterTemplates or {};c.encounterTemplateOrder=c.encounterTemplateOrder or {}
end

function DM.GetEncounterTemplates(campaignID)
    local c=DM.GetCampaign(campaignID);local out={};if not c then return out end;EnsureTemplateData(c)
    local seen={};for _,id in ipairs(c.encounterTemplateOrder) do if c.encounterTemplates[id] then out[#out+1]=c.encounterTemplates[id];seen[id]=true end end
    for id,t in pairs(c.encounterTemplates) do if not seen[id] then out[#out+1]=t end end
    return out
end

function DM.SaveEncounterTemplate(name,folderID)
    local state,err=EnsureHostState();if not state then return false,err end
    local c=DM.GetCampaign(state.campaignID);if not c then return false,"Campaign not found." end;if c.concluded then return false,"This Campaign is concluded and read-only." end
    name=Trim(name);if name=="" then return false,"Enter a template name." end;EnsureTemplateData(c)
    local id=NewID("encounter");local t={id=id,name=name:sub(1,56),createdAt=time(),folderID=(folderID and c.folders and c.folders[folderID]) and folderID or nil,beacons={},allowPlayerDamage=state.allowPlayerDamage and true or false,playerHealthVisible=state.playerHealthVisible~=false,healthSetupMax=state.healthSetupMax}
    for marker,b in pairs(state.beacons or {}) do t.beacons[marker]={npcID=b.npcID,currentHP=b.currentHP,maxHP=b.maxHP,name=b.name,title=b.title} end
    if state.initiative and state.initiative.active then t.initiativeHideOrder=state.initiative.hideOrder and true or false end
    c.encounterTemplates[id]=t;c.encounterTemplateOrder[#c.encounterTemplateOrder+1]=id
    LogLocal(state,"Encounter preset saved: "..t.name..".","HOST_ONLY",nil,"TEMPLATE")
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true,t
end

function DM.DeleteEncounterTemplate(campaignID,templateID)
    local c=DM.GetCampaign(campaignID);if not c then return false,"Campaign not found." end;if c.concluded then return false,"This Campaign is concluded and read-only." end;EnsureTemplateData(c)
    if not c.encounterTemplates[templateID] then return false,"Template not found." end
    c.encounterTemplates[templateID]=nil;for i=#c.encounterTemplateOrder,1,-1 do if c.encounterTemplateOrder[i]==templateID then table.remove(c.encounterTemplateOrder,i) end end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end;return true
end

function DM.RenameEncounterPreset(campaignID,presetID,name)
    return DM.RenameCampaignFile(campaignID,"ENCOUNTER",presetID,name)
end

DM.GetEncounterPresets=DM.GetEncounterTemplates
DM.SaveEncounterPreset=DM.SaveEncounterTemplate
DM.DeleteEncounterPreset=DM.DeleteEncounterTemplate

function DM.LoadEncounterTemplate(templateID)
    local state,err=EnsureHostState();if not state then return false,err end
    local c=DM.GetCampaign(state.campaignID);if not c then return false,"Campaign not found." end;EnsureTemplateData(c)
    local t=c.encounterTemplates[templateID];if not t then return false,"Template not found." end
    for marker in pairs(state.beacons or {}) do Broadcast("BEACON_CLEAR",marker) end
    state.beacons={};state.pendingDamage={};state.allowPlayerDamage=t.allowPlayerDamage and true or false;state.playerHealthVisible=t.playerHealthVisible~=false;state.healthSetupMax=t.healthSetupMax
    for marker,saved in pairs(t.beacons or {}) do
        local npc=c.npcs and c.npcs[saved.npcID]
        if npc then
            local max=ClampInt(saved.maxHP or npc.maxHP,1,1000000) or npc.maxHP
            local current=ClampInt(saved.currentHP or max,0,max) or max
            state.beacons[tonumber(marker) or marker]={marker=tonumber(marker) or marker,npcID=npc.id,name=npc.name,title=npc.title,currentHP=current,maxHP=max}
        end
    end
    DM.BroadcastSessionHeader();for marker,b in pairs(state.beacons) do Broadcast("BEACON",marker,b.npcID,b.name,b.currentHP,b.maxHP,b.title or "") end
    PublicLog("Encounter preset loaded: "..t.name..".","TEMPLATE")
    if state.healthSetupMax then DM.RequestPlayerHealth(state.healthSetupMax) end
    if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end;if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    return true,t
end

-- ---------------------- Campaign Pause / Resume ----------------------
function DM.HasPausedCampaign(campaignID)
    local c=DM.GetCampaign(campaignID);return c and type(c.pausedDMState)=="table" and true or false
end

local function SnapshotHostState(state)
    local snap={savedAt=time(),campaignID=state.campaignID,campaignName=state.campaignName,startedAt=state.startedAt,beacons=CopyTable(state.beacons or {}),notifications=CopyTable(state.notifications or {}),log=CopyTable(state.log or {}),pendingDamage=CopyTable(state.pendingDamage or {}),allowPlayerDamage=state.allowPlayerDamage and true or false,playerHealthVisible=state.playerHealthVisible~=false,healthSetupMax=state.healthSetupMax,initiative=CopyTable(state.initiative),participants=CopyTable(state.participants or {})}
    snap.roll=nil;snap.pendingInitiative=nil
    return snap
end

function DM.PauseCampaign()
    local state,err=EnsureHostState();if not state then return false,err end
    local c=DM.GetCampaign(state.campaignID);if not c then return false,"Campaign not found." end
    LogLocal(state,"Campaign paused and checkpoint saved.","HOST_ONLY",nil,"SESSION")
    ArchiveHostSession(state)
    c.pausedDMState=SnapshotHostState(state)
    Broadcast("END","Campaign paused by the DM.")
    DM.state=nil
    if RPAL.TeardownDMSessionUI then RPAL.TeardownDMSessionUI() end
    if RPAL.OnDMSessionEnded then RPAL.OnDMSessionEnded() end
    if RPAL.CloseDMRollUI then RPAL.CloseDMRollUI(true) end
    if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end
    if RPAL.RefreshDMKitAvailability then RPAL.RefreshDMKitAvailability() end;if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end;if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end;if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
    return true
end

function DM.ResumeCampaign(campaignID)
    if DM.state then return false,"A DM session is already active." end
    local c=DM.GetCampaign(campaignID) or DM.GetActiveCampaign();if not c or type(c.pausedDMState)~="table" then return false,"No paused campaign checkpoint." end
    if c.concluded then return false,"This Campaign has already been concluded." end
    local snap=CopyTable(c.pausedDMState);local me=LocalKey()
    local participants=snap.participants or {}
    for name,p in pairs(participants) do p.disconnected=not SamePlayer(name,me) end
    local hostKey=ParticipantKey({participants=participants},me) or me
    participants[hostKey]=participants[hostKey] or {trp=LocalTRPName(),joinedAt=time(),isHost=true}
    participants[hostKey].isHost=true;participants[hostKey].disconnected=nil;ApplyLocalStyle(participants[hostKey])
    DM.state={sessionID=NewID("dmsession"),campaignID=c.id,campaignName=c.name,host=hostKey,role="HOST",startedAt=snap.startedAt or time(),resumedAt=time(),segmentStartedAt=time(),participants=participants,beacons=snap.beacons or {},notifications=snap.notifications or {},log=snap.log or {},pendingDamage=snap.pendingDamage or {},allowPlayerDamage=snap.allowPlayerDamage and true or false,playerHealthVisible=snap.playerHealthVisible~=false,healthSetupMax=snap.healthSetupMax,roll=nil,initiative=snap.initiative,pendingInitiative=nil}
    c.pausedDMState=nil;Data().activeCampaignID=c.id
    LogLocal(DM.state,"Campaign resumed from saved checkpoint.","HOST_ONLY",nil,"SESSION")
    if RPAL.OnDMSessionBegan then RPAL.OnDMSessionBegan() end
    if RPAL.RefreshDMKitAvailability then RPAL.RefreshDMKitAvailability() end;if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end;if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end;if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end;if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end
    return true,DM.state
end

function DM.GetPausedRoster(campaignID)
    local c=DM.GetCampaign(campaignID);local out={};local snap=c and c.pausedDMState
    for name,p in pairs(snap and snap.participants or {}) do if not p.isHost then out[#out+1]={name=name,p=p} end end
    return out
end

-- ---------------------- Campaign conclusion / participation rewards ----------------------
function DM.StartParticipationRenownTicker()
    if DM._participationTicker and DM._participationTicker.Cancel then DM._participationTicker:Cancel() end
    DM._participationTicker=nil
    local state=DM.state
    if not state or state.role~="PARTICIPANT" then return end
    state.campaignRenownEarned=math.max(0,math.floor(tonumber(state.campaignRenownEarned) or 0))
    if C_Timer and C_Timer.NewTicker then
        DM._participationTicker=C_Timer.NewTicker(180,function()
            local current=DM.state
            if not current or current.role~="PARTICIPANT" then
                if DM._participationTicker and DM._participationTicker.Cancel then DM._participationTicker:Cancel() end
                DM._participationTicker=nil
                return
            end
            local awarded=RPAL.AddFixedRenown and RPAL.AddFixedRenown(1,"dm_campaign_participation") or (RPAL.AddRenown and RPAL.AddRenown(1,"dm_campaign_participation",{fixed=true})) or 0
            if (tonumber(awarded) or 0)>0 then current.campaignRenownEarned=(tonumber(current.campaignRenownEarned) or 0)+awarded end
        end)
    end
end

function DM.StopParticipationRenownTicker()
    if DM._participationTicker and DM._participationTicker.Cancel then DM._participationTicker:Cancel() end
    DM._participationTicker=nil
end

function DM.ConcludeCampaign(campaignID)
    local c=DM.GetCampaign(campaignID) or DM.GetActiveCampaign()
    if not c then return false,"Campaign not found." end
    if c.concluded then return false,"This Campaign is already concluded." end
    local state=DM.state
    if state and IsHost(state) and state.campaignID~=c.id then return false,"Stop the other active Campaign Session first." end
    local concludedAt=time();local startedAt=tonumber(c.campaignStartedAt) or (state and state.startedAt) or c.createdAt or concludedAt
    if state and IsHost(state) then
        LogLocal(state,"Campaign concluded: "..c.name..".","PUBLIC",nil,"CAMPAIGN")
        ArchiveHostSession(state)
    end
    local summary=CampaignSummary(c);local duration=summary.activeDuration or math.max(0,concludedAt-startedAt)
    if state and IsHost(state) then
        for target,tp in pairs(state.participants or {}) do
            if not tp.isHost and not tp.disconnected then
                for _,member in ipairs(summary.participants or {}) do Send(target,"CONCLUDE_MEMBER",state.sessionID,member.name or "",member.trp or "",member.isHost and "1" or "0") end
                Send(target,"CONCLUDE",state.sessionID,c.id,c.name,startedAt,concludedAt,duration,LocalTRPName(),summary.npcsInteracted or 0,summary.npcsKilled or 0,summary.npcsSaved or 0,summary.rollMin or "",summary.rollMax or "",summary.sessionCount or 0)
            end
        end
    end
    c.concluded=true;c.concludedAt=concludedAt;c.pausedDMState=nil
    if RPAL.RecordConcludedDMCampaign then RPAL.RecordConcludedDMCampaign({campaignID=c.id,name=c.name,host=LocalKey(),hostTRP=LocalTRPName(),startedAt=startedAt,concludedAt=concludedAt,duration=duration,renownEarned=0,participants=summary.participants,npcsInteracted=summary.npcsInteracted,npcsKilled=summary.npcsKilled,npcsSaved=summary.npcsSaved,rollMin=summary.rollMin,rollMax=summary.rollMax,sessionCount=summary.sessionCount}) end
    if state and IsHost(state) then DM.state=nil end
    if RPAL.TeardownDMSessionUI then RPAL.TeardownDMSessionUI() end
    if RPAL.OnDMSessionEnded then RPAL.OnDMSessionEnded() end
    if RPAL.CloseDMRollUI then RPAL.CloseDMRollUI(true) end
    if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end
    if RPAL.RefreshDMKitAvailability then RPAL.RefreshDMKitAvailability() end
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
    return true,c
end

-- ---------------------- Player Status Effects / Bulk Actions ----------------------
DM.STATUS_TEMPLATES = DM.STATUS_TEMPLATES or {
    {id="STUNNED",name="Stunned"},
    {id="INCAPACITATED",name="Incapacitated"},
    {id="SLEPT",name="Slept"},
    {id="BLEEDING",name="Bleeding"},
    {id="FROZEN",name="Frozen"},
    {id="BURNING",name="Burning"},
    {id="CRIPPLED",name="Crippled"},
    {id="ROOTED",name="Rooted"},
    {id="DISORIENTED",name="Disoriented"},
    {id="FEARED",name="Feared"},
    {id="MIND_CONTROLLED",name="Mind Controlled"},
    {id="BLINDED",name="Blinded"},
    {id="DEAFENED",name="Deafened"},
    {id="DRUNK",name="Drunk"},
}

local function StatusTemplateByID(campaign,id)
    id=tostring(id or "")
    for _,entry in ipairs(DM.STATUS_TEMPLATES or {}) do if entry.id==id then return entry end end
    for _,entry in ipairs(campaign and campaign.customStatuses or {}) do if entry.id==id then return entry end end
end

function DM.GetStatusTemplates(campaignID)
    local out={}
    for _,entry in ipairs(DM.STATUS_TEMPLATES or {}) do out[#out+1]={id=entry.id,name=entry.name,builtin=true} end
    local c=DM.GetCampaign(campaignID) or DM.GetActiveCampaign()
    for _,entry in ipairs(c and c.customStatuses or {}) do out[#out+1]={id=entry.id,name=entry.name,custom=true} end
    return out
end

function DM.CreateCustomStatus(campaignID,name,folderID)
    local c=DM.GetCampaign(campaignID) or DM.GetActiveCampaign();if not c then return false,"Campaign not found." end
    if c.concluded then return false,"This Campaign is concluded and read-only." end
    name=Trim(name);if name=="" then return false,"Enter a status name." end
    name=name:sub(1,32);c.customStatuses=c.customStatuses or {}
    for _,entry in ipairs(DM.GetStatusTemplates(c.id)) do if tostring(entry.name):lower()==name:lower() then return false,"That status already exists." end end
    local entry={id=NewID("status"),name=name,folderID=(folderID and c.folders and c.folders[folderID]) and folderID or nil,createdAt=time()};c.customStatuses[#c.customStatuses+1]=entry
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true,entry
end

local function BroadcastPlayerStatus(name,statusID,label,enabled)
    local state=DM.state;if not IsHost(state) then return end
    Broadcast("PLAYER_STATUS",name,statusID,label or "",enabled and "1" or "0")
end

function DM.SetPlayerStatus(name,statusID,enabled,label)
    local state,err=EnsureHostState();if not state then return false,err end
    local key=ParticipantKey(state,name);local p=key and state.participants[key]
    if not p then return false,"Player not found." end
    local campaign=DM.GetCampaign(state.campaignID)
    local template=StatusTemplateByID(campaign,statusID)
    label=Trim(label or (template and template.name) or statusID)
    if label=="" then return false,"Invalid status." end
    p.statusEffects=p.statusEffects or {}
    if enabled then p.statusEffects[tostring(statusID)]=label else p.statusEffects[tostring(statusID)]=nil end
    BroadcastPlayerStatus(key,tostring(statusID),label,enabled)
    PublicLog((p.trp or ShortName(key))..(enabled and " gained " or " lost ")..label..".","STATUS")
    if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
    return true
end

function DM.ApplyStatusToPlayers(playerNames,statusID,enabled,label)
    local state,err=EnsureHostState();if not state then return false,err end
    if type(playerNames)~="table" or #playerNames==0 then return false,"Select at least one player." end
    local changed=0
    for _,name in ipairs(playerNames) do
        local ok=DM.SetPlayerStatus(name,statusID,enabled,label)
        if ok then changed=changed+1 end
    end
    if changed==0 then return false,"No selected player could be updated." end
    return true,changed
end

function DM.GetPlayerStatuses(name)
    local state=DM.state;local key=state and ParticipantKey(state,name);local p=key and state.participants[key]
    local out={};for id,label in pairs(p and p.statusEffects or {}) do out[#out+1]={id=id,name=label} end
    table.sort(out,function(a,b)return tostring(a.name)<tostring(b.name) end);return out
end

function DM.ApplyPlayerDeltaBulk(playerNames,amount,isHealing)
    local state,err=EnsureHostState();if not state then return false,err end
    if type(playerNames)~="table" or #playerNames==0 then return false,"Select at least one player." end
    amount=ClampInt(amount,0,1000000);if not amount then return false,"Invalid amount." end
    local changed,skipped=0,0
    for _,name in ipairs(playerNames) do
        local ok=DM.ApplyPlayerDelta(name,amount,isHealing);if ok then changed=changed+1 else skipped=skipped+1 end
    end
    if changed==0 then return false,"Selected players do not have health configured." end
    return true,changed,skipped
end

function DM.SetPlayerHPBulk(playerNames,currentHP)
    local state,err=EnsureHostState();if not state then return false,err end
    if type(playerNames)~="table" or #playerNames==0 then return false,"Select at least one player." end
    currentHP=ClampInt(currentHP,0,1000000);if currentHP==nil then return false,"Invalid health value." end
    local changed=0
    for _,name in ipairs(playerNames) do
        local key=ParticipantKey(state,name);local pp=key and state.participants[key]
        if pp and pp.maxHP then local value=math.min(currentHP,pp.maxHP);local ok=DM.SetPlayerHP(key,value,pp.maxHP);if ok then changed=changed+1 end end
    end
    if changed==0 then return false,"Selected players do not have health configured." end
    return true,changed
end

function DM.SetPlayerMaxHPBulk(playerNames,maxHP)
    local state,err=EnsureHostState();if not state then return false,err end
    if type(playerNames)~="table" or #playerNames==0 then return false,"Select at least one player." end
    maxHP=ClampInt(maxHP,1,1000000);if not maxHP then return false,"Invalid maximum health." end
    local changed=0
    for _,name in ipairs(playerNames) do
        local key=ParticipantKey(state,name);local pp=key and state.participants[key]
        if pp then local current=math.min(tonumber(pp.currentHP) or maxHP,maxHP);local ok=DM.SetPlayerHP(key,current,maxHP);if ok then changed=changed+1 end end
    end
    return changed>0,changed>0 and changed or "No selected player could be updated."
end

-- Extend sync with V2 live surfaces.
local LegacySyncTo=DM.SyncTo
function DM.SyncTo(target)
    local ok=LegacySyncTo(target);if not ok then return ok end
    local state=DM.state
    if IsHost(state) and state.initiative and state.initiative.active then SendInitiativeTo(target) end
    if IsHost(state) and state.roll and not state.roll.closed and IsRollTarget(state.roll,target) then
        local r=state.roll;local csv=RollTargetCSV(r.targets,state)
        Send(target,"ROLL_START",state.sessionID,r.id,r.title,r.info,r.sides,r.minimum or "",csv,r.purpose or "CHECK",r.stat or "")
        for player,value in pairs(r.results or {}) do Send(target,"ROLL_RESULT",state.sessionID,r.id,player,value,(r.rawResults and r.rawResults[player]) or value,(r.modifiers and r.modifiers[player]) or 0) end
        for player in pairs(r.skipped or {}) do Send(target,"ROLL_SKIP",state.sessionID,r.id,player) end
        if r.complete then Send(target,"ROLL_DONE",state.sessionID,r.id) end
    end
    if IsHost(state) then
        for player,pdata in pairs(state.participants or {}) do
            for statusID,label in pairs(pdata.statusEffects or {}) do Send(target,"PLAYER_STATUS",state.sessionID,player,statusID,label,"1") end
        end
    end
    return true
end

-- Wrap session lifecycle so progression UI is isolated during live DM play.
local LegacyStartSession=DM.StartSession
function DM.StartSession(campaignID)
    local ok,state=LegacyStartSession(campaignID)
    if ok and RPAL.OnDMSessionBegan then RPAL.OnDMSessionBegan() end
    return ok,state
end

local LegacyEndSession=DM.EndSession
function DM.EndSession()
    local ok,res=LegacyEndSession()
    if ok and RPAL.OnDMSessionEnded then RPAL.OnDMSessionEnded() end
    if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end
    return ok,res
end

local LegacyLeaveSession=DM.LeaveSession
function DM.LeaveSession()
    local ok,res=LegacyLeaveSession()
    if ok then DM.StopParticipationRenownTicker() end
    if ok and RPAL.OnDMSessionEnded then RPAL.OnDMSessionEnded() end
    if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end
    return ok,res
end

function DM.ShouldSuppressProgressionUI()
    return DM.state~=nil
end

local function ParseMessage(text)
    local fields={}
    local source=tostring(text or "").."|"
    for token in source:gmatch("(.-)|") do
        fields[#fields+1]=token
        if #fields>40 then break end
    end
    return fields
end

function DM.HandleMessage(prefix,text,channel,sender)
    if prefix~=PREFIX or channel~="WHISPER" or not sender then return end
    local fields=ParseMessage(text)
    if tonumber(fields[1])~=PROTOCOL then return end
    local kind=fields[2]
    for i=3,#fields do fields[i]=Decode(fields[i]) end

    if kind=="INVITE" then
        if DM.state then return end
        local sid,campaignID,campaignName,host,hostTRP=fields[3],fields[4],fields[5],fields[6],fields[7]
        if not sid or sid=="" or not SamePlayer(sender,host) then return end
        DM._incomingInvite={sessionID=sid,campaignID=campaignID,campaignName=campaignName,host=sender,hostTRP=hostTRP,receivedAt=time()}
        if RPAL.ShowDMInvite then RPAL.ShowDMInvite(DM._incomingInvite) end
        return
    end

    if kind=="BUSY" then
        if RPAL.ShowDMStatusToast then RPAL.ShowDMStatusToast(fields[4] or "The DM session is busy.") end
        return
    end

    if kind=="JOIN" then
        local state=DM.state;if not IsHost(state) or fields[3]~=state.sessionID then return end
        local player,trp=fields[4],fields[5]
        if state.roll and not state.roll.complete then Send(sender,"BUSY",state.sessionID,"Finish the current roll check, then accept the invite again.");return end
        if not SamePlayer(sender,player) then return end
        local existingKey=ParticipantKey(state,player)
        if existingKey and not (state.participants[existingKey] and state.participants[existingKey].disconnected) then return end
        if not existingKey and ParticipantCount(state)>=MAX_PARTICIPANTS then return end
        if existingKey then
            player=existingKey
            local restored=state.participants[player]
            restored.disconnected=nil;restored.joinedAt=time();restored.trp=(trp and trp~="" and trp or restored.trp or RemoteTRPName(player))
        else
            state.participants[player]={trp=(trp and trp~="" and trp or RemoteTRPName(player)),joinedAt=time(),currentHP=nil,maxHP=nil}
        end
        TrackCampaignParticipant(DM.GetCampaign(state.campaignID),player,state.participants[player].trp,false)
        -- Existing participants must learn about the new member before any
        -- shared roll check is opened, otherwise their 3D lineup would differ.
        for other,op in pairs(state.participants or {}) do
            if not op.disconnected and not SamePlayer(other,LocalKey()) and not SamePlayer(other,player) then
                SendMember(other,player,state.participants[player])
            end
        end
        PublicLog((state.participants[player].trp or ShortName(player)).." joined the DM session.","PLAYER")
        DM.SyncTo(player)
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
        return
    end

    if kind=="DECLINE" then
        local state=DM.state;if not IsHost(state) or fields[3]~=state.sessionID then return end
        LogLocal(state,ShortName(sender).." declined the DM invitation.","HOST_ONLY",nil,"PLAYER")
        return
    end

    if kind=="STYLE" then
        local sid,player=fields[3],fields[4]
        if not sid or not player or player=="" then return end
        if IsHost(DM.state) then
            local state=DM.state;if sid~=state.sessionID then return end
            local key=ParticipantKey(state,player)
            if not key or not SamePlayer(sender,key) then return end
            local p=state.participants[key]
            p.theme=tostring(fields[5] or ""):sub(1,24);p.themeHex=tostring(fields[6] or ""):sub(1,8)
            p.portraitFrame=tostring(fields[7] or "Default"):sub(1,24);p.levelBadge=tostring(fields[8] or "Initiate Seal"):sub(1,24)
            p.level=ClampInt(fields[9],1,10000) or 1
            BroadcastStyle(key,p)
            if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
            if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
            return
        end
        local state=DM.state
        if not state or state.role~="PARTICIPANT" or sid~=state.sessionID or not SamePlayer(sender,state.host) then return end
        state.participants[player]=state.participants[player] or {trp=RemoteTRPName(player),joinedAt=time()}
        local p=state.participants[player]
        p.theme=tostring(fields[5] or ""):sub(1,24);p.themeHex=tostring(fields[6] or ""):sub(1,8)
        p.portraitFrame=tostring(fields[7] or "Default"):sub(1,24);p.levelBadge=tostring(fields[8] or "Initiate Seal"):sub(1,24)
        p.level=ClampInt(fields[9],1,10000) or 1
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        return
    end

    if kind=="SESSION" then
        local sid,campaignID,campaignName,host,hostTRP=fields[3],fields[4],fields[5],fields[6],fields[7]
        if not SamePlayer(sender,host) then return end
        if not DM.state then
            DM.state={sessionID=sid,campaignID=campaignID,campaignName=campaignName,host=sender,hostTRP=hostTRP,role="PARTICIPANT",participants={},beacons={},notifications={},log={},pendingDamage={},roll=nil,initiative=nil,pendingInitiative=nil}
        end
        local state=DM.state
        if state.role~="PARTICIPANT" or state.sessionID~=sid or not SamePlayer(state.host,sender) then return end
        state.campaignName=campaignName;state.campaignID=campaignID;state.hostTRP=hostTRP
        state.allowPlayerDamage=fields[8]=="1";state.playerHealthVisible=fields[9]~="0"
        if RPAL.RefreshDMKitAvailability then RPAL.RefreshDMKitAvailability() end
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
        return
    end

    local state=DM.state
    if not state or fields[3]~=state.sessionID then return end

    if state.role=="PARTICIPANT" and not SamePlayer(sender,state.host) then return end

    if kind=="MEMBER" then
        local name,trp=fields[4],fields[5]
        if not name or name=="" then return end
        state.participants[name]=state.participants[name] or {joinedAt=time()}
        local p=state.participants[name];p.trp=trp or ShortName(name);p.currentHP=ClampInt(fields[6],0,1000000);p.maxHP=ClampInt(fields[7],1,1000000);p.isHost=fields[9]=="1";p.disconnected=fields[10]=="1"
        if SamePlayer(name,LocalKey()) then ApplyLocalStyle(p) end
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        return
    end

    if kind=="PLAYER_HP" then
        local name=ParticipantKey(state,fields[4]) or fields[4]
        state.participants[name]=state.participants[name] or {trp=RemoteTRPName(name),joinedAt=time()}
        state.participants[name].currentHP=ClampInt(fields[5],0,1000000);state.participants[name].maxHP=ClampInt(fields[6],1,1000000)
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        return
    end

    if kind=="HEALTH_REQ" then
        local maxHP=ClampInt(fields[4],1,1000000);if not maxHP then return end
        if RPAL.ShowDMHealthRequest then RPAL.ShowDMHealthRequest(maxHP) end
        return
    end

    if kind=="HEALTH_RES" and IsHost(state) then
        local player=ParticipantKey(state,fields[4]);local current=ClampInt(fields[5],0,1000000);local maxHP=ClampInt(fields[6],1,1000000)
        if not player or not SamePlayer(sender,player) or not current or not maxHP or current>maxHP then return end
        local p=state.participants[player];p.currentHP=current;p.maxHP=maxHP
        BroadcastPlayerHP(player,p)
        PublicLog((p.trp or ShortName(player)).." set starting health to "..current.." / "..maxHP..".","HEALTH")
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        return
    end

    if kind=="BEACON" then
        local marker=ClampInt(fields[4],1,8);local current=ClampInt(fields[7],0,1000000);local maxHP=ClampInt(fields[8],1,1000000)
        if not marker or not current or not maxHP then return end
        state.beacons[marker]={marker=marker,npcID=fields[5],name=fields[6],currentHP=current,maxHP=maxHP,title=fields[9] or ""}
        if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end
        return
    end

    if kind=="BEACON_CLEAR" then
        local marker=ClampInt(fields[4],1,8);if marker then state.beacons[marker]=nil end
        if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        return
    end

    if kind=="DMG_REQ" and IsHost(state) then
        local requestID,marker,amount=fields[4],ClampInt(fields[5],1,8),ClampInt(fields[6],1,1000000)
        local player=ParticipantKey(state,sender)
        if not state.allowPlayerDamage or not requestID or not marker or not amount or not player or not state.beacons[marker] then return end
        state.pendingDamage=state.pendingDamage or {}
        if state.pendingDamage[requestID] then return end
        local outstanding=0;for _,r in pairs(state.pendingDamage) do if SamePlayer(r.player,player) and r.marker==marker then outstanding=outstanding+1 end end
        if outstanding>=1 then return end
        state.pendingDamage[requestID]={id=requestID,player=player,marker=marker,amount=amount,t=time()}
        LogLocal(state,(state.participants[player].trp or ShortName(player)).." requested "..amount.." damage to "..state.beacons[marker].name..".","HOST_ONLY",nil,"NPC")
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        return
    end

    if kind=="DMG_RES" then
        if RPAL.ShowDMStatusToast then RPAL.ShowDMStatusToast(fields[5]=="APPROVED" and ("Damage approved: "..fields[6]) or "Damage request rejected.") end
        return
    end

    if kind=="NOTIFY" then
        local id,t,title,body,target=fields[4],ClampInt(fields[5],0,2147483647) or time(),fields[6],fields[7],fields[8]
        AddNotificationLocal(state,id,t,title,body,target~="" and target or nil)
        if RPAL.ShowDMNotification then RPAL.ShowDMNotification(title,body) end
        return
    end

    if kind=="LOG" then
        local id,t,entryKind,text=fields[4],ClampInt(fields[5],0,2147483647) or time(),fields[6],fields[7]
        state.log=state.log or {}
        for _,e in ipairs(state.log) do if e.id==id then return end end
        state.log[#state.log+1]={id=id,t=t,kind=entryKind,text=text,visibility="PUBLIC"}
        while #state.log>MAX_LOG do table.remove(state.log,1) end
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        return
    end

    if kind=="ROLL_START" then
        local id,title,info,sides,minimum,targetCSV,purpose,stat=fields[4],fields[5],fields[6],ClampInt(fields[7],1,100),fields[8],fields[9],fields[10],fields[11]
        if not id or not VALID_DICE[sides] then return end
        minimum=(minimum and minimum~="") and ClampInt(minimum,1,120) or nil
        local targets={}
        if tostring(targetCSV or ""):sub(1,1)=="#" then
            local keys=SortedParticipantKeys(state);local mask=tostring(targetCSV):sub(2)
            for i=1,math.min(#mask,#keys) do if mask:sub(i,i)=="1" then targets[#targets+1]=keys[i] end end
        else
            for raw in tostring(targetCSV or ""):gmatch("[^,]+") do local key=ParticipantKey(state,raw) or raw;if key and key~="" then targets[#targets+1]=key end end
        end
        if #targets==0 then targets=RollTargets(state) end
        local me=ParticipantKey(state,LocalKey())
        local included=false;for _,name in ipairs(targets) do if me and SamePlayer(name,me) then included=true break end end
        if not included then return end
        state.roll={id=id,title=title,info=info,sides=sides,minimum=minimum,results={},rawResults={},modifiers={},skipped={},targets=targets,startedAt=time(),complete=false,purpose=purpose or "CHECK",stat=NormalizeRollStat(stat)}
        if RPAL.OpenDMRollUI then RPAL.OpenDMRollUI() end
        if RPAL.RefreshDMRollUI then RPAL.RefreshDMRollUI() end
        return
    end

    if kind=="ROLL" and IsHost(state) then
        local id,player,raw=fields[4],ParticipantKey(state,fields[5]),ClampInt(fields[6],1,100)
        local r=state.roll
        if not r or id~=r.id or not player or not SamePlayer(sender,player) or state.participants[player].isHost or r.results[player]~=nil or r.skipped[player] or not raw or raw>r.sides then return end
        local modifier=ClampInt(fields[7],-20,20) or 0
        if not r.stat then modifier=0 end
        local value=raw+modifier
        r.rawResults=r.rawResults or {};r.modifiers=r.modifiers or {}
        r.results[player]=value;r.rawResults[player]=raw;r.modifiers[player]=modifier
        TrackCampaignRoll(DM.GetCampaign(state.campaignID),value)
        SendToRollTargets("ROLL_RESULT",r.id,player,value,raw,modifier)
        LogRollResult(state,r,player,value)
        FinishRollIfReady(state)
        if RPAL.RefreshDMRollUI then RPAL.RefreshDMRollUI() end
        return
    end

    if kind=="ROLL_CLEAR" then
        if not state or fields[3]~=state.sessionID or state.role~="PARTICIPANT" or not SamePlayer(sender,state.host) then return end
        local r=state.roll
        if not r or fields[4]=="" or fields[4]==r.id then
            state.roll=nil
            if RPAL.CloseDMRollUI then RPAL.CloseDMRollUI(true) end
        end
        return
    end

    if kind=="ROLL_RESULT" then
        local id,player,value=fields[4],ParticipantKey(state,fields[5]) or fields[5],ClampInt(fields[6],-100,200)
        local r=state.roll;if not r or id~=r.id or value==nil then return end
        local raw=ClampInt(fields[7],1,r.sides or 100) or value
        local modifier=ClampInt(fields[8],-20,20) or 0
        r.rawResults=r.rawResults or {};r.modifiers=r.modifiers or {}
        r.results[player]=value;r.rawResults[player]=raw;r.modifiers[player]=modifier
        if RPAL.RefreshDMRollUI then RPAL.RefreshDMRollUI() end
        return
    end

    if kind=="ROLL_SKIP" then
        local id,player=fields[4],ParticipantKey(state,fields[5]) or fields[5]
        local r=state.roll;if not r or id~=r.id then return end
        r.skipped[player]=true
        if RPAL.RefreshDMRollUI then RPAL.RefreshDMRollUI() end
        return
    end

    if kind=="ROLL_DONE" then
        local r=state.roll;if not r or fields[4]~=r.id then return end
        r.complete=true
        if RPAL.RefreshDMRollUI then RPAL.RefreshDMRollUI() end
        return
    end

    if kind=="LEAVE" and IsHost(state) then
        local player=ParticipantKey(state,fields[4])
        if player and SamePlayer(sender,player) then
            local trp=state.participants[player].trp or ShortName(player);state.participants[player]=nil;PublicLog(trp.." left the DM session.","PLAYER")
            if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        end
        return
    end

    if kind=="KICK" or kind=="END" then
        if state.role=="PARTICIPANT" then
            DM.state=nil
            if RPAL.TeardownDMSessionUI then RPAL.TeardownDMSessionUI() end
            if RPAL.CloseDMRollUI then RPAL.CloseDMRollUI(true) end
            if RPAL.RefreshDMKitAvailability then RPAL.RefreshDMKitAvailability() end
            if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
            if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
            if RPAL.ShowDMStatusToast then RPAL.ShowDMStatusToast(kind=="KICK" and (fields[4] or "Removed from DM session.") or (fields[4] or "DM session ended.")) end
        end
        return
    end
end


-- V2 network messages are intercepted ahead of the legacy dispatcher.
local LegacyHandleMessage=DM.HandleMessage
DM.HandleMessage=function(prefix,text,channel,sender)
    if prefix~=PREFIX or channel~="WHISPER" or not sender then return end
    local fields=ParseMessage(text);if tonumber(fields[1])~=PROTOCOL then return end
    local kind=fields[2];for i=3,#fields do fields[i]=Decode(fields[i]) end
    local state=DM.state

    if kind=="CONCLUDE_MEMBER" then
        if not state or state.role~="PARTICIPANT" or fields[3]~=state.sessionID or not SamePlayer(sender,state.host) then return end
        state.conclusionMembers=state.conclusionMembers or {}
        local name=tostring(fields[4] or "");if name~="" then state.conclusionMembers[name:lower()]={name=name,trp=tostring(fields[5] or ""),isHost=fields[6]=="1"} end
        return
    end

    if kind=="CONCLUDE" then
        if not state or state.role~="PARTICIPANT" or fields[3]~=state.sessionID or not SamePlayer(sender,state.host) then return end
        local campaignID,campaignName=fields[4],fields[5]
        local startedAt=ClampInt(fields[6],0,2147483647) or state.startedAt or time()
        local concludedAt=ClampInt(fields[7],0,2147483647) or time()
        local duration=ClampInt(fields[8],0,2147483647) or math.max(0,concludedAt-startedAt)
        local hostTRP=fields[9] or state.hostTRP or ""
        local members={};for _,m in pairs(state.conclusionMembers or {}) do members[#members+1]=m end;table.sort(members,function(a,b)if a.isHost~=b.isHost then return a.isHost end;return tostring(a.trp or a.name)<tostring(b.trp or b.name) end)
        local npcInteracted=ClampInt(fields[10],0,1000000) or 0;local npcKilled=ClampInt(fields[11],0,1000000) or 0;local npcSaved=ClampInt(fields[12],0,1000000) or 0
        local rollMin=ClampInt(fields[13],1,100);local rollMax=ClampInt(fields[14],1,100);local sessionCount=ClampInt(fields[15],0,10000) or 0
        if RPAL.RecordConcludedDMCampaign then RPAL.RecordConcludedDMCampaign({campaignID=campaignID,name=campaignName,host=state.host,hostTRP=hostTRP,startedAt=startedAt,concludedAt=concludedAt,duration=duration,renownEarned=state.campaignRenownEarned or 0,participants=members,npcsInteracted=npcInteracted,npcsKilled=npcKilled,npcsSaved=npcSaved,rollMin=rollMin,rollMax=rollMax,sessionCount=sessionCount}) end
        DM.StopParticipationRenownTicker()
        DM.state=nil
        if RPAL.TeardownDMSessionUI then RPAL.TeardownDMSessionUI() end
        if RPAL.CloseDMRollUI then RPAL.CloseDMRollUI(true) end
        if RPAL.OnDMSessionEnded then RPAL.OnDMSessionEnded() end
        if RPAL.RefreshDMKitAvailability then RPAL.RefreshDMKitAvailability() end
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        if RPAL.RefreshDMEncounterWidget then RPAL.RefreshDMEncounterWidget() end
        if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end
        if RPAL.ShowDMStatusToast then RPAL.ShowDMStatusToast("Campaign concluded: "..tostring(campaignName or "Campaign")) end
        return
    end

    if kind=="PLAYER_STATUS" then
        if not state or fields[3]~=state.sessionID or state.role~="PARTICIPANT" or not SamePlayer(sender,state.host) then return end
        local player=ParticipantKey(state,fields[4]) or fields[4];if not player or player=="" then return end
        state.participants[player]=state.participants[player] or {trp=RemoteTRPName(player),joinedAt=time()}
        local pp=state.participants[player];pp.statusEffects=pp.statusEffects or {}
        if fields[7]=="1" then pp.statusEffects[fields[5]]=fields[6] or fields[5] else pp.statusEffects[fields[5]]=nil end
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        if RPAL.RefreshDMKitUI then RPAL.RefreshDMKitUI() end
        return
    end

    if kind=="SCREEN" then
        if not state or fields[3]~=state.sessionID or state.role~="PARTICIPANT" or not SamePlayer(sender,state.host) then return end
        if RPAL.ShowDMScreenMessage then RPAL.ShowDMScreenMessage(fields[4],fields[5],ClampInt(fields[6],2,12) or 5,fields[7],fields[8]) end
        return
    end
    if kind=="INIT_BEGIN" then
        if not state or fields[3]~=state.sessionID or state.role~="PARTICIPANT" or not SamePlayer(sender,state.host) then return end
        state.initiative={active=true,index=ClampInt(fields[4],1,100) or 1,hideOrder=fields[5]=="1",round=ClampInt(fields[6],1,10000) or 1,expected=ClampInt(fields[7],0,100) or 0,startedAt=tonumber(fields[8]) or time(),entries={}}
        return
    end
    if kind=="INIT_ENTRY" then
        if not state or fields[3]~=state.sessionID or state.role~="PARTICIPANT" or not SamePlayer(sender,state.host) or not state.initiative then return end
        local i=ClampInt(fields[4],1,100);if not i then return end
        state.initiative.entries[i]={id=fields[5],kind=fields[6],player=fields[7]~="" and fields[7] or nil,marker=ClampInt(fields[8],1,8),name=fields[9],title=fields[10],value=ClampInt(fields[11],0,1000) or 0}
        if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end
        return
    end
    if kind=="INIT_CURRENT" then
        if not state or fields[3]~=state.sessionID or state.role~="PARTICIPANT" or not SamePlayer(sender,state.host) or not state.initiative then return end
        state.initiative.index=ClampInt(fields[4],1,100) or 1;state.initiative.round=ClampInt(fields[5],1,10000) or 1
        local current=state.initiative.entries and state.initiative.entries[state.initiative.index]
        if current and current.kind=="PLAYER" and SamePlayer(current.player,LocalKey()) and RPAL.ShowDMScreenMessage then
            local p=state.participants and state.participants[current.player]
            RPAL.ShowDMScreenMessage("YOUR TURN","Round "..tostring(state.initiative.round or 1),2,"SUBTLE",p and p.themeHex or "")
        end
        if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end;if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
        return
    end
    if kind=="INIT_CLEAR" then
        if not state or fields[3]~=state.sessionID or state.role~="PARTICIPANT" or not SamePlayer(sender,state.host) then return end
        state.initiative=nil;if RPAL.RefreshDMInitiativeUI then RPAL.RefreshDMInitiativeUI() end;if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end;return
    end

    local before=DM.state
    LegacyHandleMessage(prefix,text,channel,sender)
    if kind=="SESSION" and not before and DM.state then
        if RPAL.RegisterDMCampaignJoined then RPAL.RegisterDMCampaignJoined(DM.state.campaignID,DM.state.campaignName,DM.state.host) end
        DM.state.startedAt=DM.state.startedAt or time();DM.state.campaignRenownEarned=DM.state.campaignRenownEarned or 0
        DM.StartParticipationRenownTicker()
        if RPAL.OnDMSessionBegan then RPAL.OnDMSessionBegan() end
    end
    if (kind=="END" or kind=="KICK") and before and not DM.state then
        DM.StopParticipationRenownTicker()
        if RPAL.OnDMSessionEnded then RPAL.OnDMSessionEnded() end
    end
end

function DM.SubmitHealthResponse(currentHP,maxHP)
    local state=DM.state
    if not state or state.role~="PARTICIPANT" then return false,"No DM session." end
    maxHP=ClampInt(maxHP,1,1000000);currentHP=ClampInt(currentHP,0,maxHP or 0)
    if not currentHP or not maxHP then return false,"Invalid health value." end
    Send(state.host,"HEALTH_RES",state.sessionID,LocalKey(),currentHP,maxHP)
    return true
end

local event=CreateFrame("Frame")
event:RegisterEvent("CHAT_MSG_ADDON")
event:RegisterEvent("PLAYER_ENTERING_WORLD")
event:RegisterEvent("GROUP_ROSTER_UPDATE")
event:SetScript("OnEvent",function(_,eventName,...)
    if eventName=="CHAT_MSG_ADDON" then
        DM.HandleMessage(...)
    elseif eventName=="PLAYER_ENTERING_WORLD" then
        -- Participant live state is intentionally not persisted across a reload.
        -- Campaign preparation remains in SavedVariables.
        if DM.state and DM.state.role=="PARTICIPANT" then
            DM.StopParticipationRenownTicker()
            DM.state=nil
            if RPAL.OnDMSessionEnded then RPAL.OnDMSessionEnded() end
        end
        if RPAL.RefreshDMKitAvailability then RPAL.RefreshDMKitAvailability() end
    elseif eventName=="GROUP_ROSTER_UPDATE" then
        if RPAL.RefreshDMDrawer then RPAL.RefreshDMDrawer() end
    end
end)

if C_ChatInfo and C_ChatInfo.RegisterAddonMessagePrefix then pcall(C_ChatInfo.RegisterAddonMessagePrefix,PREFIX) end
