local ADDON_NAME, RPAL = ...

-- Combat is transient. Player-authored rolls are accepted only from the
-- player they describe; every phase transition is published by the host.
local PREFIX = "RPAL_CF"
local PROTOCOL = 2
local MAX_PARTICIPANTS = 20
local REQUEST_TIMEOUT = 30
local MAX_LOG_ENTRIES = 160

local combat = RPAL.Combat or {}
RPAL.Combat = combat
combat.PREFIX = PREFIX
combat.PROTOCOL = PROTOCOL
combat.MAX_PARTICIPANTS = MAX_PARTICIPANTS
combat.state = combat.state or nil
combat._serial = combat._serial or 0
combat._actionSerial = combat._actionSerial or 0
combat._requestRate = combat._requestRate or {}

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

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

local function NormalizePlayerName(name)
    local value=tostring(name or ""):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 left=NormalizePlayerName(a)
    return left~="" and left==NormalizePlayerName(b)
end

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

local function TRPName()
    local profile=RPAL.char and RPAL.char.profile
    if profile then
        for _,key in ipairs({"displayName","characterName","name"}) do
            if type(profile[key])=="string" and profile[key]~="" then return profile[key] end
        end
        local first=tostring(profile.firstName or "")
        local surname=tostring(profile.surname or "")
        local combined=(first.." "..surname):gsub("^%s+",""):gsub("%s+$",""):gsub("%s+"," ")
        if combined~="" then return combined end
    end
    return UnitName("player") or "Player"
end

local function ValidToken(value,maxLength)
    value=tostring(value or "")
    return value~="" and #value<=(maxLength or 64) and value:match("^[%w_%-]+$")~=nil
end

local function Integer(value,minimum,maximum)
    local number=tonumber(value)
    if not number or number~=number or number==math.huge or number==-math.huge then return nil end
    if number%1~=0 then return nil end
    if number<(minimum or -2147483648) or number>(maximum or 2147483647) then return nil end
    return number
end

local function NewFightID()
    if RPAL.NewID then return RPAL.NewID("fight") end
    combat._serial=combat._serial+1
    return "fight-"..time().."-"..combat._serial.."-"..math.random(1000,9999)
end

local function NewActionID()
    combat._actionSerial=combat._actionSerial+1
    return tostring(time()).."-"..tostring(combat._actionSerial)
end

local function Safe(value)
    return tostring(value or ""):gsub("|","/"):gsub("[\r\n]"," ")
end

local function Send(target,kind,...)
    if not target or not C_ChatInfo or not C_ChatInfo.SendAddonMessage then return false end
    local parts={PREFIX,tostring(PROTOCOL),tostring(kind or "")}
    for index=1,select("#",...) do parts[#parts+1]=Safe(select(index,...)) end
    local message=table.concat(parts,"|")
    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 LocalCombatCosmetics()
    local appearance=RPAL.GetCosmeticSelection and RPAL.GetCosmeticSelection() or {}
    local theme=RPAL.GetCharacterThemeSelection and RPAL.GetCharacterThemeSelection() or {}
    return {
        theme=theme.themeManualOverride and (theme.themeName or "Obsidian Crown") or "Class Specific",
        background=appearance.background or "None",
        uiOrnament=appearance.uiOrnament or "Classic",
        effectPack=appearance.effectPack or "None",
        portraitFrame=appearance.portraitFrame or "Default",
        levelBadge=appearance.levelBadge or "Initiate Seal",
        headerDecoration=appearance.headerDecoration or "None",
        profileAura=appearance.profileAura or "None",
        platformStyle=appearance.platformStyle or "Default",
    }
end

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

local function ParticipantList(state)
    state=state or combat.state
    local result={}
    if not state then return result end
    for name,participant in pairs(state.participants or {}) do
        result[#result+1]={name=name,trp=participant.trp or ShortName(name),joinedAt=participant.joinedAt or 0}
    end
    table.sort(result,function(a,b)
        if a.joinedAt==b.joinedAt then return a.name<b.name end
        return a.joinedAt<b.joinedAt
    end)
    return result
end

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

local function IsHost(state)
    return state and SamePlayer(state.host,LocalKey())
end

local function PushState()
    if RPAL.RefreshCombatUI then RPAL.RefreshCombatUI() end
end

local function Log(state,player,trp,value,rollType,round)
    if not state then return end
    state.log=state.log or {}
    state.log[#state.log+1]={player=player,trp=trp,value=value,type=rollType,round=round or state.round,t=time()}
    while #state.log>MAX_LOG_ENTRIES do table.remove(state.log,1) end
end

local function Broadcast(kind,...)
    local state=combat.state
    if not state then return end
    for name in pairs(state.participants or {}) do
        if not SamePlayer(name,LocalKey()) then Send(name,kind,...) end
    end
end

local function HostBroadcast(kind,...)
    local state=combat.state
    if not state or not IsHost(state) then return nil end
    state.revision=(Integer(state.revision,0,1000000000) or 0)+1
    local arguments={...}
    arguments[#arguments+1]=state.revision
    Broadcast(kind,unpack(arguments))
    return state.revision
end

local function AcceptHostRevision(state,sender,revision)
    if not state or not SamePlayer(sender,state.host) then return false end
    revision=Integer(revision,1,1000000000)
    if not revision or revision<=(Integer(state.revision,0,1000000000) or 0) then return false end
    state.revision=revision
    return true
end

local function AcceptAction(state,player,actionID)
    if not state or not ValidToken(actionID,48) then return false end
    local key=NormalizePlayerName(player)
    state.seenActions=state.seenActions or {}
    local bucket=state.seenActions[key]
    if not bucket then
        bucket={map={},order={}}
        state.seenActions[key]=bucket
    end
    if bucket.map[actionID] then return false end
    bucket.map[actionID]=true
    bucket.order[#bucket.order+1]=actionID
    while #bucket.order>128 do
        local oldest=table.remove(bucket.order,1)
        bucket.map[oldest]=nil
    end
    return true
end

local function SendStyle(target)
    local state=combat.state
    if not state or not target then return end
    local cosmetics=LocalCombatCosmetics()
    local _,classToken=UnitClass("player")
    local red,green,blue=0.92,0.76,0.35
    if classToken and GetClassColor then red,green,blue=GetClassColor(classToken) end
    local hex=string.format("%02X%02X%02X",math.floor(red*255+0.5),math.floor(green*255+0.5),math.floor(blue*255+0.5))
    local displayID=C_PlayerInfo and C_PlayerInfo.GetDisplayID and C_PlayerInfo.GetDisplayID() or ""
    Send(target,"STYLE_A",state.fightID,LocalKey(),tostring(cosmetics.theme):sub(1,24),
        tostring(cosmetics.background):sub(1,24),tostring(cosmetics.uiOrnament):sub(1,24),
        tostring(cosmetics.effectPack):sub(1,24),hex,displayID)
    Send(target,"STYLE_B",state.fightID,LocalKey(),tostring(cosmetics.portraitFrame):sub(1,24),
        tostring(cosmetics.levelBadge):sub(1,24),tostring(cosmetics.headerDecoration):sub(1,24),
        tostring(cosmetics.profileAura):sub(1,24),tostring(cosmetics.platformStyle):sub(1,24))
end

local function NewState(fightID,host,participants,phase)
    return {
        fightID=fightID,
        host=host,
        phase=phase or "initiative",
        round=0,
        revision=0,
        activePlayer=nil,
        attacker=nil,
        defender=nil,
        targetSelected=false,
        participants=participants or {},
        initiative={},
        log={},
        pendingJoins={},
        seenActions={},
        lastRoll=nil,
        lastAttack=nil,
        lastDefense=nil,
        attackOrder=nil,
        attackIndex=1,
        roundAttacks={},
        defenseQueue={},
        defenseIndex=0,
        defenseTotal=0,
    }
end

local function ResetForInitiative(state,clearLog)
    if not state then return end
    state.phase="initiative"
    state.round=0
    state.activePlayer=nil
    state.attacker=nil
    state.defender=nil
    state.targetSelected=false
    state.initiative={}
    state.attackOrder=nil
    state.attackIndex=1
    state.roundAttacks={}
    state.defenseQueue={}
    state.defenseIndex=0
    state.defenseTotal=0
    state.lastRoll=nil
    state.lastAttack=nil
    state.lastDefense=nil
    state._initiativeResolving=nil
    state._resolvingDefense=nil
    state._resolvingAction=nil
    state._awaitingHostTransition=nil
    if clearLog then
        state.log={}
        state.pendingJoins={}
    end
end

local function BuildAttackOrder(state)
    local list=ParticipantList(state)
    table.sort(list,function(a,b)
        local left=tonumber(state.initiative[a.name]) or 0
        local right=tonumber(state.initiative[b.name]) or 0
        if left==right then
            if a.joinedAt==b.joinedAt then return a.name<b.name end
            return a.joinedAt<b.joinedAt
        end
        return left>right
    end)
    local result={}
    for _,entry in ipairs(list) do result[#result+1]=entry.name end
    return result
end

local function NextPlayer(state,name)
    local list=ParticipantList(state)
    if #list<2 then return name end
    for index,entry in ipairs(list) do
        if SamePlayer(entry.name,name) then return list[index%#list+1].name end
    end
    return list[1].name
end

local BeginAttackRound

local function BeginDuelTurn(state,attacker,roundNumber)
    if not state then return end
    if #ParticipantList(state)~=2 or not ParticipantKey(state,attacker) then
        BeginAttackRound(state,roundNumber)
        return
    end
    attacker=ParticipantKey(state,attacker)
    local defender=NextPlayer(state,attacker)
    state.round=roundNumber or ((state.round or 0)+1)
    state.attackOrder={attacker,defender}
    state.attackIndex=1
    state.roundAttacks={}
    state.defenseQueue={}
    state.defenseIndex=0
    state.defenseTotal=0
    state.phase="turn"
    state.attacker=attacker
    state.defender=defender
    state.activePlayer=attacker
    state.targetSelected=true
    state.lastAttack=nil
    state.lastDefense=nil
    state.lastRoll=nil
    state._resolvingDefense=nil
    state._resolvingAction=nil
    state._awaitingHostTransition=nil
    HostBroadcast("DUEL_TURN",state.fightID,state.round,attacker,defender)
    PushState()
end

local function BeginDuelDefense(state,attack)
    if not state or not attack then return end
    state.defenseQueue={attack}
    state.defenseIndex=1
    state.defenseTotal=1
    state.phase="defend"
    state.attacker=attack.attacker
    state.defender=attack.target
    state.activePlayer=attack.target
    state.targetSelected=true
    state.lastDefense=nil
    state._awaitingHostTransition=nil
    HostBroadcast("DEFEND_PHASE",state.fightID,state.round,1,attack.attacker,attack.target,1,attack.value,attack.critical and "1" or "0")
    PushState()
end

BeginAttackRound=function(state,roundNumber)
    if not state then return end
    local order=BuildAttackOrder(state)
    if #order<2 then
        state.phase="ended"
        PushState()
        return
    end
    if #order==2 then
        BeginDuelTurn(state,order[1],roundNumber or ((state.round or 0)+1))
        return
    end
    state.round=roundNumber or ((state.round or 0)+1)
    state.attackOrder=order
    state.attackIndex=1
    state.roundAttacks={}
    state.defenseQueue={}
    state.defenseIndex=0
    state.defenseTotal=0
    state.phase="turn"
    state.attacker=order[1]
    state.defender=nil
    state.activePlayer=state.attacker
    state.targetSelected=false
    state.lastAttack=nil
    state.lastDefense=nil
    state.lastRoll=nil
    state._resolvingDefense=nil
    state._resolvingAction=nil
    state._awaitingHostTransition=nil
    HostBroadcast("ROUND",state.fightID,state.round,state.attacker)
    PushState()
end

local function BeginDefensePhase(state)
    if not state then return end
    local queue={}
    for _,attack in ipairs(state.roundAttacks or {}) do
        if ParticipantKey(state,attack.target) and ParticipantKey(state,attack.attacker) then queue[#queue+1]=attack end
    end
    state.defenseQueue=queue
    state.defenseIndex=1
    state.defenseTotal=#queue
    if #queue==0 then
        BeginAttackRound(state,(state.round or 0)+1)
        return
    end
    local current=queue[1]
    state.phase="defend"
    state.attacker=current.attacker
    state.defender=current.target
    state.activePlayer=current.target
    state.targetSelected=true
    state.lastDefense=nil
    state._awaitingHostTransition=nil
    HostBroadcast("DEFEND_PHASE",state.fightID,state.round,1,current.attacker,current.target,#queue,current.value,current.critical and "1" or "0")
    PushState()
end

local function AdvanceAfterDefense(state)
    if not state then return end
    local nextIndex=(state.defenseIndex or 0)+1
    while nextIndex<=#(state.defenseQueue or {}) do
        local candidate=state.defenseQueue[nextIndex]
        if candidate and ParticipantKey(state,candidate.target) and ParticipantKey(state,candidate.attacker) then break end
        nextIndex=nextIndex+1
    end
    if nextIndex<=#(state.defenseQueue or {}) then
        state.defenseIndex=nextIndex
        local current=state.defenseQueue[nextIndex]
        state.phase="defend"
        state.attacker=current.attacker
        state.defender=current.target
        state.activePlayer=current.target
        state.targetSelected=true
        state.lastDefense=nil
        state._awaitingHostTransition=nil
        HostBroadcast("DEFEND_PHASE",state.fightID,state.round,nextIndex,current.attacker,current.target,#state.defenseQueue,current.value,current.critical and "1" or "0")
        PushState()
    else
        BeginAttackRound(state,(state.round or 0)+1)
    end
end

local function AdvanceAfterAction(state)
    if #ParticipantList(state)==2 then
        BeginDuelTurn(state,state.defender,(state.round or 0)+1)
        return
    end
    if (state.attackIndex or 1)<#(state.attackOrder or {}) then
        state.attackIndex=(state.attackIndex or 1)+1
        state.attacker=state.attackOrder[state.attackIndex]
        state.defender=nil
        state.targetSelected=false
        state.activePlayer=state.attacker
        state._awaitingHostTransition=nil
        HostBroadcast("TURN",state.fightID,state.attacker,"",state.round,state.attackIndex,#state.attackOrder)
        PushState()
    else
        BeginDefensePhase(state)
    end
end

local function ScheduleAttackAdvance(state,record)
    if not state or state._resolvingAction then return end
    state._resolvingAction=true
    PushState()
    local fightID=state.fightID
    local round=state.round
    local attackIndex=state.attackIndex or 1
    local attacker=record and record.attacker or state.attacker
    C_Timer.After(0.68,function()
        local live=combat.state
        if not live or live~=state or live.fightID~=fightID or live.phase~="turn"
           or live.round~=round or (live.attackIndex or 1)~=attackIndex or not SamePlayer(live.attacker,attacker) then return end
        live._resolvingAction=nil
        if #ParticipantList(live)==2 then BeginDuelDefense(live,record) else AdvanceAfterAction(live) end
    end)
end

local function ScheduleDefenseAdvance(state,current)
    if not state or state._resolvingDefense then return end
    state._resolvingDefense=true
    PushState()
    local fightID=state.fightID
    local round=state.round
    local defenseIndex=state.defenseIndex or 1
    C_Timer.After(1.85,function()
        local live=combat.state
        if not live or live~=state or live.fightID~=fightID or live.phase~="defend"
           or live.round~=round or (live.defenseIndex or 1)~=defenseIndex then return end
        live._resolvingDefense=nil
        if #ParticipantList(live)==2 then
            BeginDuelTurn(live,current.target,(live.round or 0)+1)
        else
            AdvanceAfterDefense(live)
        end
    end)
end

local function StartFightFromInitiative(state)
    state=state or combat.state
    if not state or state.phase~="initiative" or state._initiativeResolving then return end
    if not IsHost(state) then return end
    local list=ParticipantList(state)
    if #list<2 then return end
    for _,entry in ipairs(list) do if not state.initiative[entry.name] then return end end

    local highest=-1
    for _,entry in ipairs(list) do highest=math.max(highest,tonumber(state.initiative[entry.name]) or 0) end
    local tied=0
    for _,entry in ipairs(list) do if tonumber(state.initiative[entry.name])==highest then tied=tied+1 end end
    if tied>1 then
        ResetForInitiative(state,false)
        HostBroadcast("TIE",state.fightID)
        PushState()
        return
    end

    state._initiativeResolving=true
    state._initiativeWinner=BuildAttackOrder(state)[1]
    PushState()
    C_Timer.After(1.1,function()
        if not combat.state or combat.state~=state or not state._initiativeResolving then return end
        state._initiativeResolving=nil
        for _,entry in ipairs(ParticipantList(state)) do
            Broadcast("INIT_STATE",state.fightID,entry.name,state.initiative[entry.name])
        end
        BeginAttackRound(state,1)
    end)
end

local function RecordAttack(state,player,trp,target,value,round,critical)
    local record={attacker=player,target=target,value=value,round=round,critical=critical and true or false}
    state.roundAttacks=state.roundAttacks or {}
    state.roundAttacks[#state.roundAttacks+1]=record
    state.lastAttack={player=player,target=target,value=value,round=round}
    state.lastRoll={player=player,value=value,round=round}
    state.phase="turn"
    state.attacker=player
    state.defender=target
    state.activePlayer=player
    state.targetSelected=true
    Log(state,player,trp,value,"attack",round)
    return record
end

local function CurrentDefenseAttack(state,attacker,defender)
    local current=state and state.defenseQueue and state.defenseQueue[state.defenseIndex]
    if current and SamePlayer(current.attacker,attacker) and SamePlayer(current.target,defender) then return current end
end

local function RecordDefense(state,player,trp,attacker,value,round)
    local current=CurrentDefenseAttack(state,attacker,player)
    if not current or current.defense then return nil end
    current.defense=value
    state.lastDefense={player=player,attacker=attacker,value=value,round=round}
    state.lastRoll={player=player,value=value,round=round}
    Log(state,player,trp,value,"defend",round)
    return current
end

local function PendingJoinKey(state,candidate)
    for key in pairs(state and state.pendingJoins or {}) do
        if SamePlayer(key,candidate) then return key end
    end
end

local function DenyJoin(state,candidate,reason)
    if not state or not IsHost(state) then return end
    local key=PendingJoinKey(state,candidate) or candidate
    for name in pairs(state.participants or {}) do
        if not SamePlayer(name,LocalKey()) then Send(name,"JOIN_DENIED",state.fightID,key,reason or "declined") end
    end
    Send(key,"JOIN_DENIED",state.fightID,key,reason or "declined")
    if state.pendingJoins then state.pendingJoins[key]=nil end
    if RPAL.ShowCombatNotice then RPAL.ShowCombatNotice("JOIN DENIED",reason or "The combat vote was declined.","DECLINE") end
    PushState()
end

local function OpenHostPendingJoin(state,candidate,requester,trp)
    if not state or not IsHost(state) then return false,"Only the host can open a join vote." end
    if ParticipantCount(state)>=MAX_PARTICIPANTS then return false,"Combat is limited to 20 participants." end
    if ParticipantKey(state,candidate) then return false,"That player is already in the fight." end
    if SamePlayer(candidate,LocalKey()) then return false,"You cannot invite yourself." end
    for _ in pairs(state.pendingJoins or {}) do return false,"Another join vote is already pending." end

    state.pendingJoins=state.pendingJoins or {}
    state.pendingJoins[candidate]={
        candidate=candidate,
        requester=requester,
        requesterTRP=trp or ShortName(requester),
        trp=ShortName(candidate),
        votes={},
        consent=nil,
        createdAt=time(),
    }
    if RPAL.ShowCombatJoinRequest then RPAL.ShowCombatJoinRequest(state.fightID,candidate,requester,ShortName(candidate)) end
    for name in pairs(state.participants) do
        if not SamePlayer(name,LocalKey()) then
            Send(name,"JOIN_VOTE_REQUEST",state.fightID,candidate,requester,ShortName(candidate))
        end
    end
    Send(candidate,"JOIN_INVITE",state.fightID,state.host,requester,trp or ShortName(requester))

    local fightID=state.fightID
    local createdAt=state.pendingJoins[candidate].createdAt
    C_Timer.After(REQUEST_TIMEOUT,function()
        local live=combat.state
        local key=live and PendingJoinKey(live,candidate)
        local pending=key and live.pendingJoins[key]
        if live==state and live.fightID==fightID and pending and pending.createdAt==createdAt then
            DenyJoin(live,key,"The join vote timed out.")
        end
    end)
    return true
end

local function FinalizePendingJoin(state,candidate)
    if not state or not IsHost(state) then return false end
    local key=PendingJoinKey(state,candidate)
    local pending=key and state.pendingJoins[key]
    if not pending or pending.consent~=true then return false end
    for name in pairs(state.participants) do
        if pending.votes[name]~=true then return false end
    end
    if ParticipantCount(state)>=MAX_PARTICIPANTS then
        DenyJoin(state,key,"Combat is limited to 20 participants.")
        return false
    end

    local trp=pending.trp or ShortName(key)
    local joinedAt=time()
    state.participants[key]={trp=trp,joinedAt=joinedAt}
    state.pendingJoins[key]=nil
    ResetForInitiative(state,true)
    local revision=HostBroadcast("JOINED",state.fightID,key,trp,joinedAt) or state.revision
    Send(key,"JOIN_ACCEPTED",state.fightID,state.host,key,trp,joinedAt,revision)
    for name,participant in pairs(state.participants) do
        Send(key,"JOIN_MEMBER",state.fightID,name,participant.trp or ShortName(name),participant.joinedAt or 0,revision)
    end
    if RPAL.ShowCombatNotice then RPAL.ShowCombatNotice("COMBATANT JOINED",trp.." has joined. Initiative has been reset.","ACCEPT") end
    PushState()
    return true
end

local function ProcessSurrender(state,player,trp)
    if not state or not IsHost(state) then return end
    player=ParticipantKey(state,player)
    if not player then return end
    local count=ParticipantCount(state)
    local remaining={}
    for _,entry in ipairs(ParticipantList(state)) do if not SamePlayer(entry.name,player) then remaining[#remaining+1]=entry.name end end
    local newHost=SamePlayer(player,state.host) and (remaining[1] or "") or state.host
    Log(state,player,trp or ShortName(player),0,"surrender",state.round or 0)
    HostBroadcast("SURRENDERED",state.fightID,player,trp or ShortName(player),count,newHost or "")
    if count<=2 then
        state.surrenderedBy=player
        state.winner=remaining[1]
        state.finishedReason="surrender"
        state.phase="ended"
        state.activePlayer=nil;state.attacker=nil;state.defender=nil;state._awaitingHostTransition=nil;state._resolvingAction=nil;state._resolvingDefense=nil
        -- The player who surrendered leaves immediately. The remaining player
        -- keeps the final state so the UI can show You won + Log + Close.
        if SamePlayer(player,LocalKey()) then
            combat.state=nil
            combat._pendingRequest=nil
            combat._pendingJoinInvite=nil
            if RPAL.CloseCombatUI then RPAL.CloseCombatUI() end
        else
            PushState()
        end
        return
    end

    state.participants[player]=nil
    state.host=newHost
    ResetForInitiative(state,true)
    if SamePlayer(player,LocalKey()) then
        combat.state=nil
        combat._pendingRequest=nil
        combat._pendingJoinInvite=nil
        if RPAL.CloseCombatUI then RPAL.CloseCombatUI() end
    else
        PushState()
    end
end

function combat.IsInFight(name)
    local state=combat.state
    if not state then return false end
    return ParticipantKey(state,name or LocalKey())~=nil
end

function combat.GetState()
    return combat.state
end

function combat.StartRequest(target)
    target=target or (UnitExists("target") and FullName("target"))
    if not target or SamePlayer(target,LocalKey()) then return false,"Select an ODYN player first." end
    if combat.state then
        return combat.RequestJoin(target)
    end

    local fightID=NewFightID()
    local localName=LocalKey()
    local _,classToken=UnitClass("player")
    local red,green,blue=0.92,0.76,0.35
    if classToken and GetClassColor then red,green,blue=GetClassColor(classToken) end
    local hex=string.format("%02X%02X%02X",math.floor(red*255+0.5),math.floor(green*255+0.5),math.floor(blue*255+0.5))
    combat.state=NewState(fightID,localName,{
        [localName]={trp=TRPName(),joinedAt=time(),cosmetics=LocalCombatCosmetics(),cosmeticThemeHex=hex,
            displayID=C_PlayerInfo and C_PlayerInfo.GetDisplayID and C_PlayerInfo.GetDisplayID() or nil},
    },"request")
    combat._pendingRequest={fightID=fightID,target=target,createdAt=time()}
    Send(target,"REQ",fightID,localName,TRPName())
    if RPAL.ShowCombatNotice then RPAL.ShowCombatNotice("Combat Request Sent","Waiting for "..ShortName(target)..".","REQUEST") end

    C_Timer.After(REQUEST_TIMEOUT,function()
        local pending=combat._pendingRequest
        if pending and pending.fightID==fightID and combat.state and combat.state.fightID==fightID and combat.state.phase=="request" then
            combat.state=nil
            combat._pendingRequest=nil
            if RPAL.CloseCombatUI then RPAL.CloseCombatUI() end
            if RPAL.ShowCombatNotice then RPAL.ShowCombatNotice("COMBAT REQUEST EXPIRED",ShortName(target).." did not answer in time.","DECLINE") end
        end
    end)
    return true
end

function combat.AcceptRequest(fightID,sender,trp)
    local incoming=combat._incomingRequest
    if combat.state then return false,"You are already in combat." end
    if not incoming or incoming.fightID~=fightID or not SamePlayer(incoming.sender,sender) or (incoming.expiresAt or 0)<time() then
        return false,"That combat request has expired."
    end
    sender=incoming.sender
    local localName=LocalKey()
    local _,classToken=UnitClass("player")
    local red,green,blue=0.92,0.76,0.35
    if classToken and GetClassColor then red,green,blue=GetClassColor(classToken) end
    local hex=string.format("%02X%02X%02X",math.floor(red*255+0.5),math.floor(green*255+0.5),math.floor(blue*255+0.5))
    combat.state=NewState(fightID,sender,{
        [sender]={trp=trp or incoming.trp or ShortName(sender),joinedAt=time()-1,cosmetics={}},
        [localName]={trp=TRPName(),joinedAt=time(),cosmetics=LocalCombatCosmetics(),cosmeticThemeHex=hex,
            displayID=C_PlayerInfo and C_PlayerInfo.GetDisplayID and C_PlayerInfo.GetDisplayID() or nil},
    },"initiative")
    combat._incomingRequest=nil
    Send(sender,"ACCEPT",fightID,localName,TRPName())
    SendStyle(sender)
    PushState()
    return true
end

function combat.DeclineRequest(fightID,sender)
    local incoming=combat._incomingRequest
    if not incoming or incoming.fightID~=fightID or not SamePlayer(incoming.sender,sender) then return false end
    combat._incomingRequest=nil
    Send(incoming.sender,"DECLINE",fightID)
    return true
end

function combat.RollInitiative()
    local state=combat.state
    local localName=LocalKey()
    local key=state and ParticipantKey(state,localName)
    if not state or state.phase~="initiative" or not key or state.initiative[key] then return false end
    local value=math.random(1,20)
    local actionID=NewActionID()
    state.initiative[key]=value
    Log(state,key,TRPName(),value,"initiative",0)
    Broadcast("INITROLL",state.fightID,key,TRPName(),value,actionID)
    PushState()
    StartFightFromInitiative(state)
    return true
end

function combat.SelectTarget(target)
    local state=combat.state
    local localName=state and ParticipantKey(state,LocalKey())
    target=state and ParticipantKey(state,target)
    if not state or state.phase~="turn" or not localName or not SamePlayer(state.attacker,localName) then return false,"It is not your attack turn." end
    if #ParticipantList(state)==2 then return false,"In a 1v1 duel the defender is automatic." end
    if not target or SamePlayer(target,localName) then return false,"Choose another combatant as your target." end
    state.defender=target
    state.activePlayer=state.attacker
    state.targetSelected=true
    Broadcast("TARGET",state.fightID,localName,target,NewActionID())
    PushState()
    return true
end

function combat.AttackRoll()
    local state=combat.state
    local localName=state and ParticipantKey(state,LocalKey())
    if not state or state.phase~="turn" or state._awaitingHostTransition or state._resolvingAction or not localName
       or not SamePlayer(state.attacker,localName) or not state.defender then return false end
    local value=math.random(1,20)
    local critical=#ParticipantList(state)==2 and value==20
    local record=RecordAttack(state,localName,TRPName(),state.defender,value,state.round,critical)
    Broadcast("ATTACK",state.fightID,localName,TRPName(),state.defender,value,state.round,state.attackIndex or 1,
        #(state.attackOrder or {}),critical and "1" or "0",NewActionID())
    if IsHost(state) then
        ScheduleAttackAdvance(state,record)
    else
        state._awaitingHostTransition=true
        PushState()
    end
    return true
end

function combat.PassAction()
    local state=combat.state
    local localName=state and ParticipantKey(state,LocalKey())
    if not state or state.phase~="turn" or state._awaitingHostTransition or not localName
       or not SamePlayer(state.attacker,localName) or not state.defender then return false end
    Log(state,localName,TRPName(),0,"pass",state.round)
    Broadcast("PASS",state.fightID,localName,state.defender,state.round,NewActionID())
    if IsHost(state) then
        AdvanceAfterAction(state)
    else
        state._awaitingHostTransition=true
        PushState()
    end
    return true
end

function combat.DefendRoll()
    local state=combat.state
    local localName=state and ParticipantKey(state,LocalKey())
    if not state or state.phase~="defend" or state._awaitingHostTransition or not localName
       or not SamePlayer(state.defender,localName) then return false end
    local current=CurrentDefenseAttack(state,state.attacker,localName)
    if not current or current.defense then return false end
    local value=math.random(1,20)
    if not RecordDefense(state,localName,TRPName(),current.attacker,value,state.round) then return false end
    Broadcast("DEFEND",state.fightID,localName,TRPName(),current.attacker,value,state.round,state.defenseIndex or 1,
        #(state.defenseQueue or {}),NewActionID())
    if IsHost(state) then
        ScheduleDefenseAdvance(state,current)
    else
        state._awaitingHostTransition=true
        PushState()
    end
    return true
end

function combat.EndAndRoll()
    local state=combat.state
    if not state then return end
    if state.phase=="turn" then return combat.AttackRoll() end
    if state.phase=="defend" then return combat.DefendRoll() end
end

function combat.Surrender()
    local state=combat.state
    if not state or state._awaitingHostTransition then return false end
    local player=ParticipantKey(state,LocalKey())
    if not player then return false end
    if IsHost(state) then
        ProcessSurrender(state,player,TRPName())
    else
        Send(state.host,"SURRENDER",state.fightID,player,TRPName(),NewActionID())
        state._awaitingHostTransition=true
        PushState()
    end
    return true
end

function combat.CloseFinished()
    local state=combat.state
    if not state or state.phase~="ended" then return false end
    combat.state=nil
    combat._pendingRequest=nil
    combat._pendingJoinInvite=nil
    if RPAL.CloseCombatUI then RPAL.CloseCombatUI() end
    return true
end

function combat.End()
    local state=combat.state
    if not state then return end
    if not IsHost(state) then return combat.Surrender() end
    HostBroadcast("END",state.fightID,"host")
    combat.state=nil
    combat._pendingRequest=nil
    combat._pendingJoinInvite=nil
    if RPAL.CloseCombatUI then RPAL.CloseCombatUI() end
end

function combat.RequestJoin(target)
    local state=combat.state
    target=target or (UnitExists("target") and FullName("target"))
    if not state or not target then return false,"No active combat or target." end
    if ParticipantCount(state)>=MAX_PARTICIPANTS then return false,"Combat is limited to 20 participants." end
    if ParticipantKey(state,target) then return false,"That player is already in the fight." end
    if SamePlayer(target,LocalKey()) then return false,"You cannot invite yourself." end
    if IsHost(state) then
        return OpenHostPendingJoin(state,target,LocalKey(),TRPName())
    end
    Send(state.host,"JOIN_REQ",state.fightID,target,LocalKey(),TRPName())
    return true
end

function combat.VoteJoin(fightID,candidate,vote)
    local state=combat.state
    local invite=combat._pendingJoinInvite
    if (not state or state.fightID~=fightID) and invite and invite.fightID==fightID then
        if (invite.expiresAt or 0)<time() then combat._pendingJoinInvite=nil return false end
        Send(invite.host,"JOIN_CONSENT",fightID,LocalKey(),LocalKey(),TRPName(),vote and "1" or "0")
        if vote then invite.consented=true else combat._pendingJoinInvite=nil end
        return true
    end
    if not state or state.fightID~=fightID or not ParticipantKey(state,LocalKey()) then return false end
    local key=PendingJoinKey(state,candidate) or candidate
    if IsHost(state) then
        local pending=state.pendingJoins and state.pendingJoins[key]
        if not pending then return false end
        pending.votes[ParticipantKey(state,LocalKey())]=vote and true or false
        if not vote then DenyJoin(state,key,"The combat vote was declined.") else FinalizePendingJoin(state,key) end
        return true
    end
    Send(state.host,"VOTE",fightID,key,ParticipantKey(state,LocalKey()),vote and "1" or "0")
    return true
end

function combat.HandleMessage(prefix,text,channel,sender)
    if prefix~=PREFIX or type(text)~="string" or channel~="WHISPER" or #text>255 or not sender then return end
    local fields={strsplit("|",text)}
    if fields[1]~=PREFIX or tonumber(fields[2])~=PROTOCOL then return end
    local kind=fields[3]
    if not kind then return end

    if kind=="REQ" then
        local fightID,initiator,trp=fields[4],fields[5],fields[6]
        if not ValidToken(fightID,80) or not SamePlayer(sender,initiator) then return end
        if combat.state then Send(sender,"DECLINE",fightID,"busy") return end
        local now=time()
        if combat._incomingRequest and (combat._incomingRequest.expiresAt or 0)>now then Send(sender,"DECLINE",fightID,"busy") return end
        local rateKey=NormalizePlayerName(sender)
        for key,at in pairs(combat._requestRate) do if now-(tonumber(at) or 0)>300 then combat._requestRate[key]=nil end end
        if now-(combat._requestRate[rateKey] or 0)<5 then return end
        combat._requestRate[rateKey]=now
        combat._incomingRequest={fightID=fightID,sender=sender,trp=trp,expiresAt=now+REQUEST_TIMEOUT}
        if RPAL.ShowCombatRequest then RPAL.ShowCombatRequest(fightID,sender,trp) end
        C_Timer.After(REQUEST_TIMEOUT,function()
            local request=combat._incomingRequest
            if request and request.fightID==fightID and (request.expiresAt or 0)<=time() then
                combat._incomingRequest=nil
                if RPAL.ShowCombatNotice then RPAL.ShowCombatNotice("COMBAT REQUEST EXPIRED",ShortName(sender).."'s request expired.","DECLINE") end
            end
        end)
        return
    end

    if kind=="ACCEPT" then
        local fightID,player,trp=fields[4],fields[5],fields[6]
        local state=combat.state
        local pending=combat._pendingRequest
        if not state or state.fightID~=fightID or state.phase~="request" or not IsHost(state)
           or not pending or pending.fightID~=fightID or not SamePlayer(sender,pending.target)
           or not SamePlayer(sender,player) or ParticipantCount(state)>=MAX_PARTICIPANTS then return end
        state.participants[sender]={trp=trp or ShortName(sender),joinedAt=time()}
        ResetForInitiative(state,false)
        combat._pendingRequest=nil
        SendStyle(sender)
        PushState()
        return
    end

    if kind=="DECLINE" then
        local pending=combat._pendingRequest
        if pending and pending.fightID==fields[4] and SamePlayer(sender,pending.target) then
            combat.state=nil
            combat._pendingRequest=nil
            if RPAL.CloseCombatUI then RPAL.CloseCombatUI() end
            if RPAL.ShowCombatNotice then RPAL.ShowCombatNotice("Combat Declined",ShortName(sender).." declined the request.","DECLINE") end
        end
        return
    end

    if kind=="JOIN_INVITE" then
        local fightID,host,requester,trp=fields[4],fields[5],fields[6],fields[7]
        if combat.state or not ValidToken(fightID,80) or not SamePlayer(sender,host) then return end
        combat._pendingJoinInvite={fightID=fightID,host=sender,requester=requester,trp=trp,candidate=LocalKey(),expiresAt=time()+REQUEST_TIMEOUT}
        if RPAL.ShowCombatJoinInvite then RPAL.ShowCombatJoinInvite(fightID,requester,trp) end
        C_Timer.After(REQUEST_TIMEOUT,function()
            local invite=combat._pendingJoinInvite
            if invite and invite.fightID==fightID and (invite.expiresAt or 0)<=time() then combat._pendingJoinInvite=nil end
        end)
        return
    end

    if kind=="JOIN_ACCEPTED" then
        local fightID,host,candidate,trp=fields[4],fields[5],fields[6],fields[7]
        local joinedAt=Integer(fields[8],0,2147483647) or time()
        local revision=Integer(fields[9],1,1000000000)
        local invite=combat._pendingJoinInvite
        if not invite or invite.fightID~=fightID or not SamePlayer(sender,invite.host) or not SamePlayer(sender,host)
           or not SamePlayer(candidate,LocalKey()) or not revision then return end
        combat._pendingJoinInvite=nil
        combat.state=NewState(fightID,sender,{
            [LocalKey()]={trp=trp or TRPName(),joinedAt=joinedAt,cosmetics=LocalCombatCosmetics()},
        },"initiative")
        combat.state.revision=revision
        if C_PlayerInfo and C_PlayerInfo.GetDisplayID then combat.state.participants[LocalKey()].displayID=C_PlayerInfo.GetDisplayID() end
        if RPAL.OpenCombatUI then RPAL.OpenCombatUI() end
        PushState()
        return
    end

    if kind=="JOIN_DENIED" and (not combat.state or combat.state.fightID~=fields[4]) then
        local invite=combat._pendingJoinInvite
        if invite and invite.fightID==fields[4] and SamePlayer(sender,invite.host) then
            combat._pendingJoinInvite=nil
            if RPAL.ShowCombatNotice then RPAL.ShowCombatNotice("JOIN DENIED",fields[6] or "The combat group did not approve the join request.","DECLINE") end
        end
        return
    end

    local state=combat.state
    if not state or state.fightID~=fields[4] then return end

    if kind=="JOIN_MEMBER" then
        if not SamePlayer(sender,state.host) then return end
        local name,trp=fields[5],fields[6]
        local joinedAt=Integer(fields[7],0,2147483647) or 0
        if not name or name=="" or #name>80 or ParticipantCount(state)>=MAX_PARTICIPANTS and not ParticipantKey(state,name) then return end
        local existing=ParticipantKey(state,name)
        if not existing then state.participants[name]={trp=trp or ShortName(name),joinedAt=joinedAt} end
        if not SamePlayer(name,LocalKey()) then
            SendStyle(name)
            Send(name,"STYLE_REQUEST",state.fightID,LocalKey())
        end
        PushState()
        return
    end

    if kind=="STYLE_REQUEST" then
        local requester=fields[5]
        if not SamePlayer(sender,requester) or not ParticipantKey(state,sender) then return end
        SendStyle(sender)
        return
    end

    if kind=="STYLE" or kind=="STYLE_A" or kind=="STYLE_B" then
        local player=ParticipantKey(state,fields[5])
        if not player or not SamePlayer(sender,player) then return end
        local participant=state.participants[player]
        participant.cosmetics=participant.cosmetics or {}
        if kind=="STYLE" or kind=="STYLE_A" then
            local hex=tostring(fields[10] or "")
            if hex~="" and not hex:match("^%x%x%x%x%x%x$") then hex="" end
            participant.cosmetics.theme=tostring(fields[6] or "Class Specific"):sub(1,48)
            participant.cosmetics.background=tostring(fields[7] or "None"):sub(1,48)
            participant.cosmetics.uiOrnament=tostring(fields[8] or "Classic"):sub(1,48)
            participant.cosmetics.effectPack=tostring(fields[9] or "None"):sub(1,48)
            participant.cosmeticThemeHex=hex
            local displayID=Integer(fields[11],1,100000000)
            if displayID then participant.displayID=displayID end
        end
        if kind=="STYLE" then
            participant.cosmetics.portraitFrame=tostring(fields[12] or "Default"):sub(1,48)
            participant.cosmetics.levelBadge=tostring(fields[13] or "Initiate Seal"):sub(1,48)
            participant.cosmetics.headerDecoration=tostring(fields[14] or "None"):sub(1,48)
            participant.cosmetics.profileAura=tostring(fields[15] or "None"):sub(1,48)
            participant.cosmetics.platformStyle=tostring(fields[16] or "Default"):sub(1,48)
        elseif kind=="STYLE_B" then
            participant.cosmetics.portraitFrame=tostring(fields[6] or "Default"):sub(1,48)
            participant.cosmetics.levelBadge=tostring(fields[7] or "Initiate Seal"):sub(1,48)
            participant.cosmetics.headerDecoration=tostring(fields[8] or "None"):sub(1,48)
            participant.cosmetics.profileAura=tostring(fields[9] or "None"):sub(1,48)
            participant.cosmetics.platformStyle=tostring(fields[10] or "Default"):sub(1,48)
        end
        PushState()
        return
    end

    if kind=="INIT_STATE" then
        local player=ParticipantKey(state,fields[5])
        local value=Integer(fields[6],1,20)
        if state.phase~="initiative" or not SamePlayer(sender,state.host) or not player or not value then return end
        state.initiative[player]=value
        return
    end

    if kind=="INITROLL" then
        local player=ParticipantKey(state,fields[5])
        local value=Integer(fields[7],1,20)
        local actionID=fields[8]
        if state.phase~="initiative" or not player or not SamePlayer(sender,player) or not value or state.initiative[player]
           or not AcceptAction(state,player,actionID) then return end
        state.participants[player].trp=fields[6] or state.participants[player].trp
        state.initiative[player]=value
        Log(state,player,state.participants[player].trp,value,"initiative",0)
        PushState()
        StartFightFromInitiative(state)
        return
    end

    if kind=="TIE" then
        if state.phase~="initiative" or not AcceptHostRevision(state,sender,fields[5]) then return end
        ResetForInitiative(state,false)
        PushState()
        return
    end

    if kind=="DUEL_TURN" then
        local round=Integer(fields[5],1,1000000)
        local attacker=ParticipantKey(state,fields[6])
        local defender=ParticipantKey(state,fields[7])
        if not round or not attacker or not defender or SamePlayer(attacker,defender)
           or #ParticipantList(state)~=2 or not AcceptHostRevision(state,sender,fields[8]) then return end
        state.phase="turn";state.round=round;state.attacker=attacker;state.defender=defender;state.activePlayer=attacker
        state.targetSelected=true;state.attackOrder={attacker,defender};state.attackIndex=1;state.roundAttacks={}
        state.defenseQueue={};state.defenseIndex=0;state.defenseTotal=0;state.lastAttack=nil;state.lastDefense=nil;state.lastRoll=nil
        state._resolvingDefense=nil;state._resolvingAction=nil;state._awaitingHostTransition=nil
        PushState()
        return
    end

    if kind=="ROUND" then
        local round=Integer(fields[5],1,1000000)
        local attacker=ParticipantKey(state,fields[6])
        if not round or not attacker or #ParticipantList(state)<3 or not AcceptHostRevision(state,sender,fields[7]) then return end
        state.phase="turn";state.round=round;state.attacker=attacker;state.defender=nil;state.activePlayer=attacker;state.targetSelected=false
        state.attackOrder=BuildAttackOrder(state);state.attackIndex=1;state.roundAttacks={};state.defenseQueue={};state.defenseIndex=0;state.defenseTotal=0
        state.lastAttack=nil;state.lastDefense=nil;state.lastRoll=nil;state._resolvingDefense=nil;state._resolvingAction=nil;state._awaitingHostTransition=nil
        PushState()
        return
    end

    if kind=="TARGET" then
        local attacker=ParticipantKey(state,fields[5])
        local target=ParticipantKey(state,fields[6])
        if state.phase~="turn" or not attacker or not target or not SamePlayer(sender,attacker)
           or not SamePlayer(state.attacker,attacker) or SamePlayer(attacker,target)
           or not AcceptAction(state,attacker,fields[7]) then return end
        state.defender=target;state.targetSelected=true;state.activePlayer=attacker
        PushState()
        return
    end

    if kind=="ATTACK" then
        local player=ParticipantKey(state,fields[5])
        local target=ParticipantKey(state,fields[7])
        local value=Integer(fields[8],1,20)
        local round=Integer(fields[9],1,1000000)
        local attackIndex=Integer(fields[10],1,MAX_PARTICIPANTS)
        local attackTotal=Integer(fields[11],2,MAX_PARTICIPANTS)
        if state.phase~="turn" or not player or not target or not SamePlayer(sender,player)
           or not SamePlayer(state.attacker,player) or not SamePlayer(state.defender,target)
           or round~=state.round or attackIndex~=(state.attackIndex or 1) or attackTotal~=#(state.attackOrder or {})
           or not value or not AcceptAction(state,player,fields[13]) then return end
        for _,attack in ipairs(state.roundAttacks or {}) do if SamePlayer(attack.attacker,player) then return end end
        local record=RecordAttack(state,player,fields[6] or state.participants[player].trp,target,value,round,fields[12]=="1")
        PushState()
        if IsHost(state) then
            ScheduleAttackAdvance(state,record)
        end
        return
    end

    if kind=="DEFEND_PHASE" then
        local round=Integer(fields[5],1,1000000)
        local index=Integer(fields[6],1,MAX_PARTICIPANTS*MAX_PARTICIPANTS)
        local attacker=ParticipantKey(state,fields[7])
        local defender=ParticipantKey(state,fields[8])
        local total=Integer(fields[9],1,MAX_PARTICIPANTS*MAX_PARTICIPANTS)
        local attackValue=Integer(fields[10],1,20)
        if not round or not index or not attacker or not defender or not total or not attackValue
           or not AcceptHostRevision(state,sender,fields[12]) then return end
        local current
        for _,attack in ipairs(state.roundAttacks or {}) do
            if SamePlayer(attack.attacker,attacker) and SamePlayer(attack.target,defender) and attack.round==round then current=attack break end
        end
        if not current then
            current={attacker=attacker,target=defender,value=attackValue,round=round,critical=fields[11]=="1"}
            state.roundAttacks=state.roundAttacks or {};state.roundAttacks[#state.roundAttacks+1]=current
        end
        state.defenseQueue=state.defenseQueue or {}
        state.defenseQueue[index]=current
        state.phase="defend";state.round=round;state.defenseIndex=index;state.defenseTotal=total
        state.attacker=attacker;state.defender=defender;state.activePlayer=defender;state.targetSelected=true
        state._resolvingDefense=nil;state._resolvingAction=nil;state._awaitingHostTransition=nil
        PushState()
        return
    end

    if kind=="DEFEND" then
        local player=ParticipantKey(state,fields[5])
        local attacker=ParticipantKey(state,fields[7])
        local value=Integer(fields[8],1,20)
        local round=Integer(fields[9],1,1000000)
        local index=Integer(fields[10],1,MAX_PARTICIPANTS*MAX_PARTICIPANTS)
        local total=Integer(fields[11],1,MAX_PARTICIPANTS*MAX_PARTICIPANTS)
        if state.phase~="defend" or not player or not attacker or not SamePlayer(sender,player)
           or not SamePlayer(state.defender,player) or not SamePlayer(state.attacker,attacker)
           or round~=state.round or index~=(state.defenseIndex or 1) or total~=(state.defenseTotal or #(state.defenseQueue or {}))
           or not value or not AcceptAction(state,player,fields[12]) then return end
        local current=RecordDefense(state,player,fields[6] or state.participants[player].trp,attacker,value,round)
        if not current then return end
        PushState()
        if IsHost(state) then ScheduleDefenseAdvance(state,current) end
        return
    end

    if kind=="TURN" then
        local attacker=ParticipantKey(state,fields[5])
        local round=Integer(fields[7],1,1000000)
        local index=Integer(fields[8],1,MAX_PARTICIPANTS)
        local total=Integer(fields[9],2,MAX_PARTICIPANTS)
        if not attacker or not round or not index or not total or not AcceptHostRevision(state,sender,fields[10]) then return end
        if not state.attackOrder or not SamePlayer(state.attackOrder[index],attacker) then return end
        state.phase="turn";state.attacker=attacker;state.defender=nil;state.activePlayer=attacker;state.targetSelected=false
        state.round=round;state.attackIndex=index;state._resolvingAction=nil;state._awaitingHostTransition=nil
        PushState()
        return
    end

    if kind=="PASS" then
        local player=ParticipantKey(state,fields[5])
        local target=ParticipantKey(state,fields[6])
        local round=Integer(fields[7],1,1000000)
        if state.phase~="turn" or not player or not target or not SamePlayer(sender,player)
           or not SamePlayer(state.attacker,player) or not SamePlayer(state.defender,target) or round~=state.round
           or not AcceptAction(state,player,fields[8]) then return end
        Log(state,player,state.participants[player].trp or ShortName(player),0,"pass",round)
        PushState()
        if IsHost(state) then AdvanceAfterAction(state) end
        return
    end

    if kind=="SURRENDER" then
        local player=ParticipantKey(state,fields[5])
        if not IsHost(state) or not player or not SamePlayer(sender,player) or not AcceptAction(state,player,fields[7]) then return end
        ProcessSurrender(state,player,fields[6])
        return
    end

    if kind=="SURRENDERED" then
        local player=ParticipantKey(state,fields[5])
        local newHost=ParticipantKey(state,fields[8])
        if not player or not AcceptHostRevision(state,sender,fields[9]) then return end
        Log(state,player,fields[6] or ShortName(player),0,"surrender",state.round or 0)
        if ParticipantCount(state)<=2 then
            if SamePlayer(player,LocalKey()) then
                combat.state=nil
                combat._pendingRequest=nil
                combat._pendingJoinInvite=nil
                if RPAL.CloseCombatUI then RPAL.CloseCombatUI() end
                return
            end
            state.surrenderedBy=player
            state.finishedReason="surrender"
            state.phase="ended"
            for _,entry in ipairs(ParticipantList(state)) do if not SamePlayer(entry.name,player) then state.winner=entry.name break end end
            state.activePlayer=nil;state.attacker=nil;state.defender=nil;state._awaitingHostTransition=nil;state._resolvingAction=nil;state._resolvingDefense=nil
            PushState()
        else
            state.participants[player]=nil
            state.host=newHost or state.host
            ResetForInitiative(state,true)
            if SamePlayer(player,LocalKey()) then
                combat.state=nil
                if RPAL.CloseCombatUI then RPAL.CloseCombatUI() end
            else
                PushState()
            end
        end
        return
    end

    if kind=="END" then
        if not AcceptHostRevision(state,sender,fields[6]) then return end
        combat.state=nil
        combat._pendingRequest=nil
        combat._pendingJoinInvite=nil
        if RPAL.CloseCombatUI then RPAL.CloseCombatUI() end
        return
    end

    if kind=="JOIN_REQ" then
        local requester=ParticipantKey(state,fields[6])
        if not IsHost(state) or not requester or not SamePlayer(sender,requester) then return end
        OpenHostPendingJoin(state,fields[5],requester,fields[7])
        return
    end

    if kind=="JOIN_VOTE_REQUEST" then
        if not SamePlayer(sender,state.host) or not ParticipantKey(state,LocalKey()) then return end
        if RPAL.ShowCombatJoinRequest then RPAL.ShowCombatJoinRequest(state.fightID,fields[5],fields[6],fields[7]) end
        return
    end

    if kind=="VOTE" then
        local voter=ParticipantKey(state,fields[6])
        local key=PendingJoinKey(state,fields[5])
        if not IsHost(state) or not voter or not SamePlayer(sender,voter) or not key then return end
        local pending=state.pendingJoins[key]
        pending.votes[voter]=fields[7]=="1"
        if not pending.votes[voter] then DenyJoin(state,key,"A combatant voted NO.") else FinalizePendingJoin(state,key) end
        PushState()
        return
    end

    if kind=="JOIN_CONSENT" then
        local key=PendingJoinKey(state,fields[5])
        if not IsHost(state) or not key or not SamePlayer(sender,fields[6]) or not SamePlayer(sender,key) then return end
        local pending=state.pendingJoins[key]
        local candidateTRP=tostring(fields[7] or ""):sub(1,80)
        if candidateTRP~="" then pending.trp=candidateTRP end
        pending.consent=fields[8]=="1"
        if not pending.consent then DenyJoin(state,key,"The invited player declined to join.") else FinalizePendingJoin(state,key) end
        PushState()
        return
    end

    if kind=="JOINED" then
        local candidate=fields[5]
        local revision=fields[8]
        if not candidate or candidate=="" or ParticipantCount(state)>=MAX_PARTICIPANTS
           or not AcceptHostRevision(state,sender,revision) then return end
        if not ParticipantKey(state,candidate) then
            state.participants[candidate]={trp=fields[6] or ShortName(candidate),joinedAt=Integer(fields[7],0,2147483647) or time()}
        end
        ResetForInitiative(state,true)
        SendStyle(candidate)
        PushState()
        return
    end

    if kind=="JOIN_DENIED" then
        if not SamePlayer(sender,state.host) then return end
        local key=PendingJoinKey(state,fields[5])
        if key then state.pendingJoins[key]=nil end
        if RPAL.ShowCombatNotice then RPAL.ShowCombatNotice("JOIN DENIED",fields[6] or "The combat group did not approve the join request.","DECLINE") end
        PushState()
    end
end

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

SLASH_RPALFIGHT1="/odynfight"
SLASH_RPALFIGHT2="/rpalfight"
SlashCmdList["RPALFIGHT"]=function(message)
    message=tostring(message or ""):lower():gsub("^%s+",""):gsub("%s+$","")
    if message=="end" or message=="stop" then combat.End() return end
    if message=="join" then
        local ok,err=combat.RequestJoin()
        if not ok and err then print("|cffff6666ODYN Combat:|r "..err) end
        return
    end
    local ok,err=combat.StartRequest()
    if not ok and err then print("|cffff6666ODYN Combat:|r "..err) end
end
