local ADDON_NAME, RPAL = ...
local C = RPAL.Combat
local f, requestFrame
local MAX_PARTICIPANTS=(C and C.MAX_PARTICIPANTS) or 20
local TIMELINE_BOXES=12

local function ThemeAccent()
    local p = RPAL.GetThemePalette and RPAL.GetThemePalette()
    if p and p.gold then return {p.gold[1],p.gold[2],p.gold[3],1} end
    return {0.92,0.76,0.35,1}
end

local function Font(parent,text,size,color)
    local fs=parent:CreateFontString(nil,"OVERLAY","GameFontNormal")
    local base=size or 12;local fontPath="Fonts\\FRIZQT__.TTF";local final=RPAL.GetScaledFontSize and RPAL.GetScaledFontSize(base) or base
    fs:SetFont(fontPath,final,"OUTLINE")
    if RPAL.RegisterScalableFontObject then RPAL.RegisterScalableFontObject(fs,fontPath,base,"OUTLINE") end
    fs:SetText(text or "")
    if color then fs:SetTextColor(color[1],color[2],color[3],color[4] or 1) end
    return fs
end

local function HexColor(hex)
    hex=tostring(hex or ""):gsub("#","")
    if not hex:match("^%x%x%x%x%x%x$") then return ThemeAccent() end
    local r=tonumber(hex:sub(1,2),16)
    local g=tonumber(hex:sub(3,4),16)
    local b=tonumber(hex:sub(5,6),16)
    if not r or not g or not b then return ThemeAccent() end
    return {r/255,g/255,b/255,1}
end

local function LocalName()
    local n,r=UnitName("player")
    if not n then return "" end
    r=r or (GetRealmName and GetRealmName())
    return r and r~="" and n.."-"..r:gsub("%s+","") or n
end

local function ParticipantList(s)
    local list={}
    for n,p in pairs(s.participants or {}) do list[#list+1]={n=n,p=p} end
    table.sort(list,function(a,b)
        if (a.p.joinedAt or 0)==(b.p.joinedAt or 0) then return a.n<b.n end
        return (a.p.joinedAt or 0)<(b.p.joinedAt or 0)
    end)
    return list
end

local function GetCombatScale()
    local settings=RPAL.GetSettings and RPAL.GetSettings()
    return math.max(0.70,math.min(1.35,tonumber(settings and settings.uiScale) or 1))
end

local function MakeButton(parent,text,w,h)
    local b=CreateFrame("Button",nil,parent,"BackdropTemplate")
    b:SetSize(w,h)
    b:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=1})
    b:SetBackdropColor(0.035,0.04,0.055,0.94)
    b:SetBackdropBorderColor(0.32,0.28,0.20,0.9)
    b.text=Font(b,text,10,{0.84,0.84,0.80,1}); b.text:SetPoint("CENTER")
    b:SetScript("OnEnter",function(self)
        self:SetBackdropColor(0.09,0.10,0.13,0.98)
        self:SetBackdropBorderColor(0.82,0.68,0.32,1)
    end)
    b:SetScript("OnLeave",function(self)
        self:SetBackdropColor(0.035,0.04,0.055,0.94)
        self:SetBackdropBorderColor(0.32,0.28,0.20,0.9)
    end)
    if RPAL.AttachButtonMotion then RPAL.AttachButtonMotion(b,0.018) end
    return b
end

local function ClearChildren(frame)
    if not frame then return end
    for _,child in ipairs({frame:GetChildren()}) do child:Hide(); child:SetParent(nil) end
    for _,region in ipairs({frame:GetRegions()}) do if region and region.Hide then region:Hide() end end
end

local function ClearCosmeticLayer()
    if not f or not f.cosmeticLayer then return end
    for _,child in ipairs({f.cosmeticLayer:GetChildren()}) do child:Hide(); child:SetParent(nil) end
    for _,region in ipairs({f.cosmeticLayer:GetRegions()}) do if region and region.Hide then region:Hide() end end
end

local function ApplyCombatTheme(participant)
    if not f then return end
    participant=participant or {}
    local appearance=participant.cosmetics or {}
    local themeName=participant.theme or appearance.theme or "Obsidian Crown"
    local signature=table.concat({
        tostring(themeName),tostring(participant.cosmeticThemeHex or ""),
        tostring(appearance.headerDecoration or "None"),tostring(appearance.profileAura or "None"),
        tostring(appearance.platformStyle or "Default"),tostring(math.floor(f:GetWidth() or 0)),
    },"|")
    if f._combatThemeSignature==signature then return end
    f._combatThemeSignature=signature
    local palette
    if RPAL.GetThemePaletteByName then
        palette=RPAL.GetThemePaletteByName(themeName,participant.cosmeticThemeHex)
    end
    palette=palette or (RPAL.GetThemePalette and RPAL.GetThemePalette())
    if not palette then return end
    local function SetTextColor(fs,c) if fs and c then fs:SetTextColor(c[1],c[2],c[3],c[4] or 1) end end
    f:SetBackdropColor(palette.main[1],palette.main[2],palette.main[3],0.965)
    f:SetBackdropBorderColor(palette.border[1],palette.border[2],palette.border[3],0.98)
    f.timelinePanel:SetBackdropColor(palette.panel[1],palette.panel[2],palette.panel[3],0.96)
    f.timelinePanel:SetBackdropBorderColor(palette.gold[1],palette.gold[2],palette.gold[3],0.95)
    SetTextColor(f.title,palette.gold); SetTextColor(f.subtitle,palette.muted); SetTextColor(f.timeline,palette.gold); SetTextColor(f.phase,palette.muted); SetTextColor(f.timelineStatus,palette.pale)
    if f.logPanel then f.logPanel:SetBackdropColor(palette.panel[1],palette.panel[2],palette.panel[3],0.97); f.logPanel:SetBackdropBorderColor(palette.border[1],palette.border[2],palette.border[3],0.98); SetTextColor(f.logPanel.title,palette.gold) end
    local accent=palette.accent or palette.gold
    for _,holder in ipairs(f.players or {}) do
        if holder.card then
            holder.card:SetBackdropColor(palette.panel2[1],palette.panel2[2],palette.panel2[3],0.16)
        end
        if holder.roll then holder.roll:SetBackdropColor(palette.button[1],palette.button[2],palette.button[3],0.96); holder.roll:SetBackdropBorderColor(palette.border[1],palette.border[2],palette.border[3],0.96) end
        if holder.pass then holder.pass:SetBackdropColor(palette.button[1],palette.button[2],palette.button[3],0.96); holder.pass:SetBackdropBorderColor(palette.border[1],palette.border[2],palette.border[3],0.96) end
        if holder.defend then holder.defend:SetBackdropColor(palette.button[1],palette.button[2],palette.button[3],0.96); holder.defend:SetBackdropBorderColor(palette.border[1],palette.border[2],palette.border[3],0.96) end
    end
    ClearCosmeticLayer()
    if RPAL.RenderCombatHeaderCosmetic and appearance.headerDecoration and appearance.headerDecoration~="None" then
        local deco=RPAL.RenderCombatHeaderCosmetic(f.cosmeticLayer,appearance.headerDecoration,accent,math.min(620,f:GetWidth()-80),78)
        if deco then deco:SetPoint("TOP",f,"TOP",0,-30); deco:SetAlpha(0.82) end
    end
    if RPAL.RenderCombatProfileAura and appearance.profileAura and appearance.profileAura~="None" then
        local aura=RPAL.RenderCombatProfileAura(f.cosmeticLayer,f.title,appearance.profileAura,accent,88)
        if aura then aura:SetAlpha(0.36) end
    end
    if RPAL.RenderProfilePlatform and appearance.platformStyle and appearance.platformStyle~="Default" then
        local platform=RPAL.RenderProfilePlatform(f.cosmeticLayer,appearance.platformStyle,accent,math.min(430,f:GetWidth()-180),54,false)
        if platform then platform:SetPoint("TOP",f.timelinePanel,"TOP",0,4); platform:SetAlpha(0.24) end
    end
end

local function ApplyCombatBackgroundForParticipant(participant)
    if not f or not f.bgHolder then return end
    participant=participant or {}
    local appearance=participant.cosmetics or {}
    local themeName=participant.theme or appearance.theme
    local signature=table.concat({
        tostring(themeName or ""),tostring(participant.cosmeticThemeHex or ""),
        tostring(appearance.background or "None"),tostring(appearance.uiOrnament or "Classic"),
        tostring(appearance.effectPack or "None"),
    },"|")
    if f._combatBackgroundSignature==signature then return end
    f._combatBackgroundSignature=signature
    ClearChildren(f.bgHolder)
    local themeAccent=HexColor(participant.cosmeticThemeHex)
    if (not participant.cosmeticThemeHex or participant.cosmeticThemeHex=="") and RPAL.GetThemePalette then
        local p=RPAL.GetThemePalette()
        if p and p.gold then themeAccent={p.gold[1],p.gold[2],p.gold[3],1} end
    end
    if RPAL.RenderAtmosphere then
        pcall(RPAL.RenderAtmosphere,f.bgHolder,appearance.background or "None",themeAccent,0.48,false)
    end
    if RPAL.RenderUIOrnament then
        pcall(RPAL.RenderUIOrnament,f.bgHolder,appearance.uiOrnament or "Classic",themeAccent,4)
    end
    if RPAL.RenderEffectPack then
        pcall(RPAL.RenderEffectPack,f.bgHolder,appearance.effectPack or "None",themeAccent,{preview=true,frameLevelOffset=1})
    end
end

local function GetCombatVisualWinner(s,list)
    if not s or not list or #list==0 then return nil end
    local bestName,bestValue=nil,-1
    if s.phase=="initiative" then
        for _,item in ipairs(list) do
            local v=tonumber(s.initiative[item.n])
            if v and v>bestValue then bestName,bestValue=item.n,v end
        end
    elseif s.lastDefense and s.lastAttack and s.lastDefense.attacker==s.lastAttack.player then
        local av=tonumber(s.lastAttack.value) or -1
        local dv=tonumber(s.lastDefense.value) or -1
        bestName=(dv>av) and s.lastDefense.player or s.lastAttack.player
    elseif s.phase=="turn" then
        for _,attack in ipairs(s.roundAttacks or {}) do
            local v=tonumber(attack.value)
            if v and v>bestValue then bestName,bestValue=attack.attacker,v end
        end
        if not bestName then bestName=s.attacker end
    elseif s.phase=="defend" then
        local current=s.defenseQueue and s.defenseQueue[s.defenseIndex]
        if current then
            local av=tonumber(current.value) or -1
            local dv=tonumber(current.defense) or -1
            if dv>=0 and dv>av then bestName=current.target else bestName=current.attacker end
        end
    end
    if bestName and s.participants[bestName] then return s.participants[bestName] end
    return s.participants[LocalName()] or list[1].p
end

local function PanelAccent(p)
    return HexColor(p and p.cosmeticThemeHex)
end

local function ResetScore(panel)
    if not panel or not panel.score then return end
    panel.score:SetScale(1); panel.score:SetAlpha(1); panel.score:SetText("—")
    panel.score:SetTextColor(0.92,0.92,0.88,1)
    if panel.flash then panel.flash:SetAlpha(0) end
    if panel.scoreAnim then panel.scoreAnim:SetScript("OnUpdate",nil) end
    panel.lastAnimKey=nil
end

local function FlashPanel(panel,color,duration)
    if not panel or not panel.flash then return end
    panel.flash:SetColorTexture(color[1],color[2],color[3],1)
    panel.flash:SetAlpha(0.55)
    local fr=panel.flashDriver or CreateFrame("Frame",nil,panel)
    panel.flashDriver=fr
    local t0=GetTime()
    fr:SetScript("OnUpdate",function(self)
        local t=GetTime()-t0
        if t>=duration then panel.flash:SetAlpha(0); self:SetScript("OnUpdate",nil); return end
        panel.flash:SetAlpha(0.55*(1-t/duration))
    end)
end

local function AnimateRoll(panel,value,key,accent,isCritical)
    if not panel or not value or panel.lastAnimKey==key then return end
    panel.lastAnimKey=key
    local fs=panel.score
    local driver=panel.scoreAnim or CreateFrame("Frame",nil,panel)
    panel.scoreAnim=driver
    local started=GetTime()
    local duration=0.60
    local final=tonumber(value) or 0
    local isMin=final==1
    local isMax=final==20
    local neutral={0.92,0.92,0.88,1}
    local finalColor=isMin and {1.0,0.18,0.16,1} or (isMax and {0.20,1.0,0.35,1} or neutral)
    fs:SetScale(1.08); fs:SetAlpha(1); fs:SetTextColor(neutral[1],neutral[2],neutral[3],1)
    driver:SetScript("OnUpdate",function(self)
        local t=GetTime()-started
        if t>=duration then
            fs:SetText(tostring(final)..(isMax and "!" or ""))
            fs:SetScale(1.0)
            fs:SetAlpha(1)
            -- Critical/minimum colour is deliberately revealed only with the authoritative final number.
            fs:SetTextColor(finalColor[1],finalColor[2],finalColor[3],1)
            self:SetScript("OnUpdate",nil)
            if isMax then FlashPanel(panel,{0.18,1.0,0.30,1},0.45)
            elseif isMin then FlashPanel(panel,{1.0,0.16,0.14,1},0.38)
            else FlashPanel(panel,accent,0.28) end
            return
        end
        -- Never leak the final result through colour while the die is still shuffling.
        fs:SetText(tostring(math.random(1,20)))
        local pulse=1.08 + 0.10*math.sin(t/duration*math.pi*5)
        fs:SetScale(pulse)
        fs:SetTextColor(neutral[1],neutral[2],neutral[3],1)
    end)
end

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

local function UnitMatchesName(unit, name)
    if not UnitExists(unit) then return false end
    local n,r=UnitName(unit)
    if not n then return false end
    local full=r and r~="" and (n.."-"..r:gsub("%s+","")) or n
    return NormalizeUnitName(full)==NormalizeUnitName(name)
end

local function FindUnitToken(name)
    if UnitMatchesName("player",name) then return "player" end
    if UnitMatchesName("target",name) then return "target" end
    if UnitMatchesName("focus",name) then return "focus" end
    for i=1,4 do
        if UnitMatchesName("party"..i,name) then return "party"..i end
    end
    for i=1,40 do
        if UnitMatchesName("raid"..i,name) then return "raid"..i end
    end
    return nil
end

local PlayModelAnimation

local WEAPON_PROFILES={
    unarmed={label="UNARMED",ready=25,attack=16},
    dagger={label="DAGGER",ready=26,attack=85},
    onehand={label="ONE-HAND",ready=26,attack=17},
    twohand={label="TWO-HAND",ready=27,attack=18},
    staff={label="STAFF",ready=27,attack=18},
    polearm={label="POLEARM",ready=28,attack=19},
    ranged={label="RANGED",ready=29,attack=46},
    rifle={label="RIFLE / CROSSBOW",ready=48,attack=49},
    wand={label="WAND",ready=51,attack=53},
}

local function GetWeaponProfileForUnit(unit)
    unit=unit or "player"
    local mainID,offID
    pcall(function() mainID=GetInventoryItemID(unit,16) end)
    pcall(function() offID=GetInventoryItemID(unit,17) end)
    local itemID=mainID or offID
    local profile=WEAPON_PROFILES.unarmed
    local label=profile.label
    local subtypeID,equipLoc
    if itemID and GetItemInfoInstant then
        local _,_,_,loc,_,classID,subClassID=GetItemInfoInstant(itemID)
        equipLoc=loc
        if classID==2 then subtypeID=subClassID end
    end
    if subtypeID~=nil then
        if subtypeID==0 then profile=WEAPON_PROFILES.onehand
        elseif subtypeID==1 then profile=WEAPON_PROFILES.twohand
        elseif subtypeID==2 then profile=WEAPON_PROFILES.ranged
        elseif subtypeID==3 or subtypeID==17 then profile=WEAPON_PROFILES.rifle
        elseif subtypeID==4 or subtypeID==7 or subtypeID==9 or subtypeID==13 then profile=WEAPON_PROFILES.onehand
        elseif subtypeID==5 or subtypeID==8 then profile=WEAPON_PROFILES.twohand
        elseif subtypeID==6 then profile=WEAPON_PROFILES.polearm
        elseif subtypeID==10 then profile=WEAPON_PROFILES.staff
        elseif subtypeID==15 then profile=WEAPON_PROFILES.dagger
        elseif subtypeID==18 then profile=WEAPON_PROFILES.wand
        end
        label=profile.label
    elseif equipLoc=="INVTYPE_2HWEAPON" then
        profile=WEAPON_PROFILES.twohand; label=profile.label
    elseif equipLoc=="INVTYPE_RANGED" or equipLoc=="INVTYPE_RANGEDRIGHT" then
        profile=WEAPON_PROFILES.ranged; label=profile.label
    end
    return {mainID=mainID,offID=offID,itemID=itemID,label=label,ready=profile.ready,attack=profile.attack}
end

local function GetModelWeaponProfile(model,token)
    local unit=token or (model and model._rpalUnitToken) or "player"
    return GetWeaponProfileForUnit(unit)
end

local function ForceModelWeaponsUnsheathed(model)
    if not model then return end
    -- DressUpModel exposes SetSheathed(), which affects only the preview model;
    -- it does not change the real player's sheath state. Keep weapons drawn for
    -- the entire combat-ready presentation and before every action animation.
    if model.SetSheathed then
        pcall(model.SetSheathed,model,false,false)
    end
end

local function PlayCombatReadyIdle(model,profile)
    if not model or not model:IsShown() then return end
    profile=profile or WEAPON_PROFILES.unarmed
    if model.SetPaused then pcall(model.SetPaused,model,false) end
    ForceModelWeaponsUnsheathed(model)
    if model.SetAnimation then pcall(model.SetAnimation,model,profile.ready or 0)
    elseif model.SetSequence then pcall(model.SetSequence,model,profile.ready or 0) end
    -- Some model animations can restore the sheath state when they begin, so
    -- explicitly force it once more after selecting the ready animation.
    ForceModelWeaponsUnsheathed(model)
    model._rpalCombatReady=true
    model._rpalWeaponProfile=profile
end

local function PlayCombatWeaponAttack(model,profile)
    if not model or not model:IsShown() then return end
    profile=profile or WEAPON_PROFILES.unarmed
    ForceModelWeaponsUnsheathed(model)
    local sequence=profile.attack or WEAPON_PROFILES.unarmed.attack
    if model.HasAnimation then
        local ok,has=pcall(model.HasAnimation,model,sequence)
        if ok and not has then
            local fallback=WEAPON_PROFILES.onehand.attack
            if profile==WEAPON_PROFILES.ranged or profile==WEAPON_PROFILES.rifle then fallback=profile.attack end
            sequence=fallback
        end
    end
    PlayModelAnimation(model,sequence,1.05)
    ForceModelWeaponsUnsheathed(model)
end

local function SetupCombatModel(model,name,participant)
    if not model then return false end

    -- Prefer a real unit token. SetUnit preserves the unit's actual race,
    -- gender, gear and customization; SetDisplayInfo alone only supplies a
    -- display ID and can render the character as an untextured/white model.
    local token=FindUnitToken(name)
    local weaponProfile=token and GetWeaponProfileForUnit(token) or WEAPON_PROFILES.unarmed
    local setupKey=table.concat({
        tostring(name or ""),tostring(token or ""),tostring(participant and participant.displayID or ""),
        tostring(weaponProfile.mainID or ""),tostring(weaponProfile.offID or ""),
    },"|")
    if model._rpalSetupKey==setupKey then
        if token then model:Show(); return true end
        model:Hide(); return false
    end
    model._rpalSetupKey=setupKey
    if token then
        model:Show()
        model._rpalUnitToken=token
        if model.SetAutoDress then pcall(model.SetAutoDress,model,true) end
        pcall(function() model:ClearModel() end)
        local ok=pcall(function() model:SetUnit(token) end)
        if ok then
            pcall(function() if model.SetPortraitZoom then model:SetPortraitZoom(0) end end)
            pcall(function() if model.SetCamDistanceScale then model:SetCamDistanceScale(0.82) end end)
            pcall(function() if model.SetFacing then model:SetFacing(0.12) elseif model.SetRotation then model:SetRotation(0.12) end end)
            model._rpalWeaponProfile=weaponProfile
            ForceModelWeaponsUnsheathed(model)
            PlayCombatReadyIdle(model,weaponProfile)
            return true
        end
    end

    -- For an arbitrary remote player there is no supported way to reconstruct
    -- the full character appearance from a name/display ID alone. Do not show
    -- the misleading white display-info model as a fallback.
    model:Hide()
    model._rpalUnitToken=nil
    return false
end

local function MakePlayerPanel(parent)
    local holder=CreateFrame("Frame",nil,parent)
    holder:SetSize(220,232)
    holder:EnableMouse(false)

    local frame=CreateFrame("Frame",nil,holder,"BackdropTemplate")
    frame:SetSize(220,190)
    frame:SetPoint("TOP")
    frame:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=1})
    frame:SetBackdropColor(0.012,0.016,0.025,0.18)
    frame:SetBackdropBorderColor(0.30,0.27,0.22,0)

    -- DressUpModel inherits the PlayerModel functionality but also exposes
    -- SetSheathed(), allowing the combat preview to keep equipped weapons drawn.
    local model=CreateFrame("DressUpModel",nil,frame)
    model:SetSize(155,180)
    model:SetPoint("LEFT",8,-4)
    model:EnableMouse(false)
    model:SetFrameLevel(frame:GetFrameLevel()+1)
    model:Hide()
    holder.model=model

    frame.name=Font(frame,"",11,{0.90,0.90,0.86,1})
    frame.name:SetPoint("TOPRIGHT",-8,-10)
    frame.name:SetWidth(60)
    frame.name:SetJustifyH("CENTER")

    frame.score=Font(frame,"—",32,{0.92,0.92,0.88,1})
    frame.score:SetPoint("TOPRIGHT",-8,-46)
    frame.score:SetWidth(60)
    frame.score:SetJustifyH("CENTER")

    frame.scoreLabel=Font(frame,"ROLL",7,{0.50,0.50,0.54,1})
    frame.scoreLabel:SetPoint("TOPRIGHT",-8,-86)
    frame.scoreLabel:SetWidth(60)
    frame.scoreLabel:SetJustifyH("CENTER")

    frame.flash=frame:CreateTexture(nil,"ARTWORK")
    frame.flash:SetAllPoints(); frame.flash:SetAlpha(0); frame.flash:SetBlendMode("ADD")

    frame.selectHint=Font(frame,"SELECT TARGET",8,{0.72,0.64,0.42,1})
    frame.selectHint:SetPoint("BOTTOMRIGHT",-8,30)
    frame.selectHint:SetWidth(60)
    frame.selectHint:SetJustifyH("CENTER")
    frame.selectHint:Hide()

    frame.role=Font(frame,"",7,{0.60,0.60,0.64,1})
    frame.role:SetPoint("BOTTOMRIGHT",-7,10)
    frame.role:SetWidth(62)
    frame.role:SetJustifyH("CENTER")
    frame.role:SetWordWrap(true)

    holder.card=frame
    holder.roll=MakeButton(holder,"ROLL ACTION",94,27)
    holder.roll:SetPoint("TOPRIGHT",frame,"BOTTOM",-4,-4)
    holder.pass=MakeButton(holder,"PASS",72,27)
    holder.pass:SetPoint("TOPLEFT",frame,"BOTTOM",4,-4)
    holder.defend=MakeButton(holder,"ROLL DEFENSE",118,27)
    holder.defend:SetPoint("TOP",frame,"BOTTOM",0,-4)
    holder.roll:Hide(); holder.pass:Hide(); holder.defend:Hide()

    frame:SetScript("OnEnter",function(self)
        if self._selectable and holder.model then holder.model:SetAlpha(1) end
    end)
    frame:SetScript("OnLeave",function(self)
        if holder.model then holder.model:SetAlpha(self._selectable and 1 or 0.92) end
    end)
    frame:SetScript("OnMouseDown",function(self)
        if self._selectable and self._targetName then
            C.SelectTarget(self._targetName)
        end
    end)
    frame:EnableMouse(true)
    frame._baseBorder={0.30,0.27,0.22,0.95}
    return holder
end

local function LayoutPlayers(list)
    local n=#list
    local spacing=12
    local cols=n<=4 and math.max(1,n) or 4
    local rows=math.max(1,math.ceil(n/cols))
    local cellW=232
    local cellH=246
    local totalW=cols*cellW+(cols-1)*spacing
    local contentW=math.max(totalW,math.floor((f.playerScroll:GetWidth() or totalW)))
    local startX=math.max(0,(contentW-totalW)/2)
    f.playerContent:SetSize(contentW,math.max(1,rows*cellH))
    for i=1,MAX_PARTICIPANTS do
        local holder=f.players[i]
        if i<=n then
            local row=math.floor((i-1)/cols)
            local col=(i-1)%cols
            holder:ClearAllPoints()
            holder:SetPoint("TOPLEFT",f.playerContent,"TOPLEFT",startX+col*(cellW+spacing)+6,-row*cellH)
            holder:Show()
        else
            holder:Hide()
        end
    end
    f.playerScroll:UpdateScrollChildRect()
    local maxScroll=f.playerScroll:GetVerticalScrollRange() or 0
    f.playerScroll:SetVerticalScroll(math.max(0,math.min(maxScroll,f.playerScroll:GetVerticalScroll() or 0)))
end

PlayModelAnimation=function(model,sequence,duration)
    if not model or not model:IsShown() then return end
    local played=false
    if model.SetAnimation then played=pcall(model.SetAnimation,model,sequence) end
    if not played and model.SetSequence then played=pcall(model.SetSequence,model,sequence) end
    if C_Timer and C_Timer.After then
        C_Timer.After(duration or 0.8,function()
            if model and model:IsShown() then
                PlayCombatReadyIdle(model,model._rpalWeaponProfile or WEAPON_PROFILES.unarmed)
            end
        end)
    end
end

local function AttackVisualKey(a,d)
    return tostring(a.player)..":"..tostring(a.target)..":"..tostring(a.value)..":"..tostring(d.value)..":"..tostring(a.round)
end

local function StartCombatAttackVisual(s,a,d,key)
    if not s or not a or not d then return end
    if (tonumber(d.value) or 0) >= (tonumber(a.value) or 0) then
        f._lastAttackVisualKey=key
        return
    end
    f._lastAttackVisualKey=key

    local attackerIndex,targetIndex
    for i,item in ipairs(ParticipantList(s)) do
        if item.n==a.player then attackerIndex=i end
        if item.n==a.target then targetIndex=i end
    end
    if not attackerIndex or not targetIndex then return end
    local attacker=f.players[attackerIndex]
    local target=f.players[targetIndex]
    if not attacker or not target or not attacker:IsShown() or not target:IsShown() then return end
    local model=attacker.model
    if not model or not model:IsShown() then return end

    local ax=attacker:GetCenter() or 0
    local tx=target:GetCenter() or ax
    local direction=(tx>=ax) and 1 or -1
    local distance=math.abs(tx-ax)
    local travel=math.max(45,math.min(150,distance*0.55))*direction
    local startTime=GetTime()
    local duration=0.72
    local driver=f.attackVisualDriver or CreateFrame("Frame",nil,f)
    f.attackVisualDriver=driver
    driver:SetScript("OnUpdate",function(self)
        if not model or not model:IsShown() then self:SetScript("OnUpdate",nil); return end
        local t=(GetTime()-startTime)/duration
        if t>=1 then
            model:ClearAllPoints(); model:SetPoint("LEFT",attacker.card,8,-4)
            self:SetScript("OnUpdate",nil)
            return
        end
        local eased
        if t<0.58 then
            local q=t/0.58; eased=q*q*(3-2*q)
        else
            local q=(t-0.58)/0.42; eased=1-(q*q*(3-2*q))
        end
        model:ClearAllPoints(); model:SetPoint("LEFT",attacker.card,8+travel*eased,-4)
    end)
    local profile=model._rpalWeaponProfile or GetModelWeaponProfile(model,model._rpalUnitToken)
    PlayCombatWeaponAttack(model,profile)
end

local function PlayCombatAttackVisual(s)
    -- Defense result arrives before its 0.60s roll reveal has finished.  Queue
    -- the attack so the final die number is visible first, then animate.
    if not s or not s.lastAttack or not s.lastDefense then return end
    local a=s.lastAttack
    local d=s.lastDefense
    if d.attacker~=a.player or d.player~=a.target or d.round~=a.round then return end
    local key=AttackVisualKey(a,d)
    if f._lastAttackVisualKey==key or f._scheduledAttackVisualKey==key then return end
    f._scheduledAttackVisualKey=key
    C_Timer.After(0.64,function()
        if f._scheduledAttackVisualKey~=key then return end
        f._scheduledAttackVisualKey=nil
        local live=C and C.GetState and C.GetState() or nil
        if not live or not live.lastAttack or not live.lastDefense then return end
        local la,ld=live.lastAttack,live.lastDefense
        if ld.attacker~=la.player or ld.player~=la.target or ld.round~=la.round or AttackVisualKey(la,ld)~=key then return end
        StartCombatAttackVisual(live,la,ld,key)
    end)
end

local function StartDefenseReactionVisual(s,a,d,key)
    f._lastDefenseReactionKey=key
    local list=ParticipantList(s)
    local defenderIndex,attackerIndex
    for i,item in ipairs(list) do
        if item.n==d.player then defenderIndex=i end
        if item.n==a.player then attackerIndex=i end
    end
    local defender=f.players[defenderIndex or 0]
    local attacker=f.players[attackerIndex or 0]
    if not defender or not defender.model or not defender.model:IsShown() then return end

    local dv=tonumber(d.value) or 0
    local av=tonumber(a.value) or 0
    local model=defender.model
    local animation
    local duration=0.85

    if dv<av then
        animation=28 -- native character-model emote_cry animation
        duration=0.95
    else
        local choices={4,26,30}
        local idx=((av+dv+(s.round or 0))%#choices)+1
        animation=choices[idx]
    end
    if model.HasAnimation then
        local ok,has=pcall(model.HasAnimation,model,animation)
        if ok and not has then animation=30 end
    end
    PlayModelAnimation(model,animation,duration)
end

local function PlayDefenseReactionVisual(s)
    if not s or not s.lastAttack or not s.lastDefense then return end
    local a=s.lastAttack
    local d=s.lastDefense
    if d.attacker~=a.player or d.player~=a.target or d.round~=a.round then return end
    local key=AttackVisualKey(a,d)
    if f._lastDefenseReactionKey==key or f._scheduledDefenseReactionKey==key then return end
    f._scheduledDefenseReactionKey=key
    C_Timer.After(0.64,function()
        if f._scheduledDefenseReactionKey~=key then return end
        f._scheduledDefenseReactionKey=nil
        local live=C and C.GetState and C.GetState() or nil
        if not live or not live.lastAttack or not live.lastDefense then return end
        local la,ld=live.lastAttack,live.lastDefense
        if ld.attacker~=la.player or ld.player~=la.target or ld.round~=la.round or AttackVisualKey(la,ld)~=key then return end
        StartDefenseReactionVisual(live,la,ld,key)
    end)
end

local function NameOf(s,name)
    return name and s.participants[name] and (s.participants[name].trp or name) or (name or "—")
end

local function TimelineText(s)
    if s.phase=="initiative" then
        return "INITIATIVE  →  ALL COMBATANTS ROLL"
    end
    if s.phase=="turn" then
        local idx=s.attackIndex or 1
        local total=#(s.attackOrder or {})
        return string.format("ROUND %d  •  ATTACK TURN %d/%d  •  %s",s.round or 1,idx,total,NameOf(s,s.attacker))
    end
    if s.phase=="defend" then
        return string.format("ROUND %d  •  DEFENSE %d/%d  •  %s DEFENDS AGAINST %s",s.round or 1,s.defenseIndex or 1,s.defenseTotal or #(s.defenseQueue or {}),NameOf(s,s.defender),NameOf(s,s.attacker))
    end
    return "COMBAT ENDED"
end

local function TimelineStatusText(s)
    local participantTotal=#ParticipantList(s)
    if s.phase=="initiative" then
        if participantTotal>TIMELINE_BOXES then
            local ready=0
            local waiting
            for _,item in ipairs(ParticipantList(s)) do
                if s.initiative[item.n] then ready=ready+1 elseif not waiting then waiting=NameOf(s,item.n) end
            end
            return string.format("READY %d/%d  •  WAITING: %s",ready,participantTotal,waiting or "—")
        end
        local parts={}
        for _,item in ipairs(ParticipantList(s)) do
            local mark=s.initiative[item.n] and ("✓ "..NameOf(s,item.n).." "..tostring(s.initiative[item.n])) or ("… "..NameOf(s,item.n).." WAITING")
            parts[#parts+1]=mark
        end
        return table.concat(parts,"     ")
    elseif s.phase=="turn" then
        if participantTotal>TIMELINE_BOXES then
            local done=0
            for _,item in ipairs(s.roundAttacks or {}) do if item and item.attacker then done=done+1 end end
            return string.format("ACTIONS %d/%d  •  CURRENT: %s",done,participantTotal,NameOf(s,s.attacker))
        end
        local parts={}
        for _,item in ipairs(s.attackOrder or {}) do
            local label=NameOf(s,item)
            local done=false
            for _,attack in ipairs(s.roundAttacks or {}) do if attack.attacker==item then done=true break end end
            if item==s.attacker and not done then label=label.."  •  YOUR TURN" end
            if done then label=label.."  ✓" end
            parts[#parts+1]=label
        end
        return table.concat(parts,"     ")
    elseif s.phase=="defend" then
        if participantTotal>TIMELINE_BOXES then
            return string.format("DEFENSE %d/%d  •  %s ← %s",s.defenseIndex or 1,s.defenseTotal or #(s.defenseQueue or {}),NameOf(s,s.defender),NameOf(s,s.attacker))
        end
        local parts={}
        for i,attack in ipairs(s.defenseQueue or {}) do
            local text=NameOf(s,attack.target).." ← "..NameOf(s,attack.attacker)
            if attack.defense then text=text.."  ✓" elseif i==(s.defenseIndex or 0) then text=text.."  • NOW" end
            parts[#parts+1]=text
        end
        return table.concat(parts,"     ")
    end
    return ""
end

local function ClearLog(panel)
    if not panel or not panel.logContent then return end
    local s=C.GetState(); local y=0
    for i,e in ipairs(s and s.log or {}) do
        local text=(e.trp or e.player).."  •  "..tostring(e.value).."  •  "..(e.type or "roll").."  •  R"..tostring(e.round or 0)
        local line=panel.logLines[i]
        if not line then
            line=Font(panel.logContent,"",9,{0.68,0.68,0.65,1})
            line:SetWidth(255); line:SetJustifyH("LEFT"); line:SetWordWrap(true)
            panel.logLines[i]=line
        end
        line:ClearAllPoints(); line:SetPoint("TOPLEFT",8,-y); line:SetText(text); line:Show(); y=y+17
    end
    for i=#(s and s.log or {})+1,#(panel.logLines or {}) do panel.logLines[i]:Hide() end
    panel.logContent:SetHeight(math.max(1,y)); panel.logScroll:UpdateScrollChildRect()
end

local function Build()
    if f then return end
    f=CreateFrame("Frame","RPAL_CombatWindow",UIParent,"BackdropTemplate")
    f:SetSize(760,520); f:SetPoint("CENTER"); if RPAL.ApplyUILayer then RPAL.ApplyUILayer(f,"WORKSPACE",80) else f:SetFrameStrata("DIALOG");f:SetFrameLevel(400);f:SetToplevel(true) end; f:EnableMouse(true); f:SetMovable(true); f:SetClampedToScreen(true)
    f:SetScale(GetCombatScale())
    f:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=1})
    f:SetBackdropColor(0.018,0.02,0.028,0.96); f:SetBackdropBorderColor(0.26,0.23,0.19,0.95)

    f.bgHolder=CreateFrame("Frame",nil,f); f.bgHolder:SetAllPoints(); f.bgHolder:SetFrameLevel(f:GetFrameLevel()+1); f.bgHolder:EnableMouse(false)
    f.bgHolder:Show()
    f.cosmeticLayer=CreateFrame("Frame",nil,f); f.cosmeticLayer:SetAllPoints(); f.cosmeticLayer:SetFrameLevel(f:GetFrameLevel()+2); f.cosmeticLayer:EnableMouse(false)
    f.title=Font(f,"COMBAT",14,ThemeAccent()); f.title:SetPoint("TOP",0,-10)
    f.subtitle=Font(f,"ATTACK → DEFEND → SWAP",8,{0.48,0.48,0.52,1}); f.subtitle:SetPoint("TOP",0,-27)

    f.playerScroll=CreateFrame("ScrollFrame",nil,f)
    f.playerScroll:SetPoint("TOPLEFT",20,-158)
    f.playerScroll:SetPoint("BOTTOMRIGHT",-20,52)
    f.playerScroll:EnableMouseWheel(true)
    f.playerScroll:SetScript("OnMouseWheel",function(self,delta)
        local maximum=self:GetVerticalScrollRange() or 0
        self:SetVerticalScroll(math.max(0,math.min(maximum,(self:GetVerticalScroll() or 0)-delta*72)))
    end)
    f.playerContent=CreateFrame("Frame",nil,f.playerScroll)
    f.playerContent:SetSize(1,1)
    f.playerScroll:SetScrollChild(f.playerContent)
    f.players={}
    for i=1,MAX_PARTICIPANTS do f.players[i]=MakePlayerPanel(f.playerContent); f.players[i]:Hide() end

    f.timelinePanel=CreateFrame("Frame",nil,f,"BackdropTemplate")
    f.timelinePanel:SetSize(600,110); f.timelinePanel:SetPoint("TOP",0,-42); f.timelinePanel:SetFrameLevel(f:GetFrameLevel()+3)
    f.timelinePanel:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=1})
    f.timelinePanel:SetBackdropColor(0.025,0.028,0.038,0.94); f.timelinePanel:SetBackdropBorderColor(0.55,0.43,0.22,0.95)
    f.timeline=Font(f.timelinePanel,"",12,{0.92,0.78,0.40,1}); f.timeline:SetPoint("TOP",0,-8); f.timeline:SetWidth(570); f.timeline:SetJustifyH("CENTER")
    f.timelineRoster=CreateFrame("Frame",nil,f.timelinePanel); f.timelineRoster:SetPoint("TOPLEFT",10,-42); f.timelineRoster:SetPoint("TOPRIGHT",-10,0); f.timelineRoster:SetHeight(50)
    f.timelineBoxes={}
    for i=1,TIMELINE_BOXES do
        local b=CreateFrame("Frame",nil,f.timelineRoster,"BackdropTemplate")
        b:SetSize(112,48); b:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=1})
        b:SetBackdropColor(0.02,0.025,0.035,0.92); b:SetBackdropBorderColor(0.25,0.24,0.20,0.85)
        -- Use the same native circular unit portrait used by the player frame,
        -- not a 3D model. This keeps the timeline lightweight and visually
        -- consistent with Blizzard's unit-frame portrait treatment.
        b.portrait= b:CreateTexture(nil,"ARTWORK")
        b.portrait:SetSize(40,40)
        b.portrait:SetPoint("LEFT",3,0)
        b.portrait:Hide()
        b.portraitMask=b:CreateMaskTexture(nil,"ARTWORK")
        b.portraitMask:SetTexture("Interface\\CharacterFrame\\TempPortraitAlphaMask")
        b.portraitMask:SetAllPoints(b.portrait)
        b.portrait:AddMaskTexture(b.portraitMask)
        b.portrait._rpalUnitToken=nil
        b.name=Font(b,"",7,{0.88,0.88,0.84,1}); b.name:SetPoint("LEFT",b.portrait,"RIGHT",4,7); b.name:SetWidth(62); b.name:SetJustifyH("LEFT")
        b.mark=Font(b,"",7,{0.62,0.62,0.60,1}); b.mark:SetPoint("LEFT",b.portrait,"RIGHT",4,-9); b.mark:SetWidth(62); b.mark:SetJustifyH("LEFT")
        f.timelineBoxes[i]=b
    end
    f.timelineStatus=Font(f.timelinePanel,"",8,{0.66,0.68,0.66,1}); f.timelineStatus:SetPoint("BOTTOM",0,7); f.timelineStatus:SetWidth(570); f.timelineStatus:SetJustifyH("CENTER"); f.timelineStatus:SetWordWrap(true)
    f.phase=Font(f.timelinePanel,"",7,{0.52,0.52,0.54,1}); f.phase:SetPoint("TOP",0,-25); f.phase:SetWidth(570); f.phase:SetJustifyH("CENTER")
    f.surrender=MakeButton(f,"SURRENDER / LEAVE",125,25); f.surrender:SetPoint("BOTTOM",0,19); f.surrender:SetFrameLevel(f:GetFrameLevel()+5); f.surrender:SetScript("OnClick",function() if C.Surrender then C.Surrender() end end)

    f.victoryText=Font(f,"You won!",34,{0.92,0.78,0.40,1});f.victoryText:SetPoint("CENTER",0,24);f.victoryText:SetJustifyH("CENTER");f.victoryText:Hide()
    f.finishedLog=MakeButton(f,"VIEW LOG",110,30);f.finishedLog:SetPoint("CENTER",-60,-42);f.finishedLog:Hide()
    f.finishedClose=MakeButton(f,"CLOSE",110,30);f.finishedClose:SetPoint("CENTER",60,-42);f.finishedClose:Hide();f.finishedClose:SetScript("OnClick",function()if C.CloseFinished then C.CloseFinished() else RPAL.CloseCombatUI() end end)

    f.logToggle=MakeButton(f,"LOG",42,24); f.logToggle:SetPoint("RIGHT",f,"LEFT",2,0)
    f.logOpen=false
    f.logPanel=CreateFrame("Frame",nil,f,"BackdropTemplate"); f.logPanel:SetSize(285,250); f.logPanel:SetPoint("RIGHT",f.logToggle,"LEFT",-4,0); f.logPanel:SetFrameStrata("DIALOG"); f.logPanel:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=1}); f.logPanel:SetBackdropColor(0.02,0.025,0.035,0.96); f.logPanel:SetBackdropBorderColor(0.28,0.25,0.20,0.95); f.logPanel:Hide()
    f.logPanel.title=Font(f.logPanel,"COMBAT LOG",10,ThemeAccent()); f.logPanel.title:SetPoint("TOP",0,-10)
    f.logPanel.logScroll=CreateFrame("ScrollFrame",nil,f.logPanel); f.logPanel.logScroll:SetPoint("TOPLEFT",10,-30); f.logPanel.logScroll:SetPoint("BOTTOMRIGHT",-10,10); f.logPanel.logScroll:EnableMouseWheel(true)
    f.logPanel.logScroll:SetScript("OnMouseWheel",function(self,delta) local max=self:GetVerticalScrollRange(); self:SetVerticalScroll(math.max(0,math.min(max,self:GetVerticalScroll()-delta*28))) end)
    f.logPanel.logContent=CreateFrame("Frame",nil,f.logPanel.logScroll); f.logPanel.logContent:SetSize(255,1); f.logPanel.logScroll:SetScrollChild(f.logPanel.logContent); f.logPanel.logLines={}
    f.logToggle:SetScript("OnClick",function() f.logOpen=not f.logOpen; f.logPanel:SetShown(f.logOpen); if f.logOpen then ClearLog(f.logPanel) end end)
    f.finishedLog:SetScript("OnClick",function() f.logOpen=not f.logOpen;f.logPanel:SetShown(f.logOpen);if f.logOpen then ClearLog(f.logPanel) end end)

    f:SetScript("OnMouseDown",function(self,button) if button=="LeftButton" and not (f.logPanel and f.logPanel:IsMouseOver()) then self:StartMoving() end end)
    f:SetScript("OnMouseUp",function(self,button) if button=="LeftButton" then self:StopMovingOrSizing() end end)
    f:SetScript("OnHide",function(self)
        if self.attackVisualDriver then self.attackVisualDriver:SetScript("OnUpdate",nil) end
        self._scheduledAttackVisualKey=nil;self._scheduledDefenseReactionKey=nil
        for _,holder in ipairs(self.players or {}) do
            local card=holder and holder.card
            if card and card.scoreAnim then card.scoreAnim:SetScript("OnUpdate",nil) end
            if card and card.flashDriver then card.flashDriver:SetScript("OnUpdate",nil) end
        end
    end)
    f:Hide()
end

local function EnsureRequest()
    if requestFrame then return end
    requestFrame=CreateFrame("Frame","RPAL_CombatRequest",UIParent,"BackdropTemplate"); requestFrame:SetSize(390,210); requestFrame:SetPoint("CENTER",0,120); if RPAL.ApplyUILayer then RPAL.ApplyUILayer(requestFrame,"MODAL",500)else requestFrame:SetFrameStrata("FULLSCREEN_DIALOG");requestFrame:SetFrameLevel(8500);requestFrame:SetToplevel(true)end
    requestFrame:SetBackdrop({bgFile="Interface\\Buttons\\WHITE8X8",edgeFile="Interface\\Buttons\\WHITE8X8",edgeSize=2}); requestFrame:SetBackdropColor(0.02,0.025,0.035,0.98); requestFrame:SetBackdropBorderColor(0.65,0.48,0.24,1)
    requestFrame.title=Font(requestFrame,"COMBAT REQUEST",16,{0.88,0.76,0.45,1}); requestFrame.title:SetPoint("TOP",0,-22)
    requestFrame.body=Font(requestFrame,"",12,{0.76,0.76,0.72,1}); requestFrame.body:SetPoint("TOP",0,-62); requestFrame.body:SetWidth(330); requestFrame.body:SetJustifyH("CENTER")
    local a=MakeButton(requestFrame,"ACCEPT",130,36); a:SetPoint("BOTTOMLEFT",42,24)
    local d=MakeButton(requestFrame,"DECLINE",130,36); d:SetPoint("BOTTOMRIGHT",-42,24)
    requestFrame.accept=a; requestFrame.decline=d
    a:SetScript("OnClick",function()
        local data=requestFrame.data
        if not data then return end
        local ok,err
        if data.join then ok=C.VoteJoin(data.fightID,data.candidate,true)
        elseif data.invite then ok=C.VoteJoin(data.fightID,LocalName(),true)
        else ok,err=C.AcceptRequest(data.fightID,data.sender,data.trp) end
        requestFrame:Hide()
        if ok and not data.join and not data.invite then RPAL.OpenCombatUI()
        elseif not ok and err and RPAL.ShowCombatNotice then RPAL.ShowCombatNotice("REQUEST EXPIRED",err) end
    end)
    d:SetScript("OnClick",function()
        local data=requestFrame.data
        if not data then return end
        if data.join then C.VoteJoin(data.fightID,data.candidate,false)
        elseif data.invite then C.VoteJoin(data.fightID,LocalName(),false)
        else C.DeclineRequest(data.fightID,data.sender) end
        requestFrame:Hide()
    end)
    requestFrame:Hide()
end

function RPAL.ShowCombatRequest(fightID,sender,trp) EnsureRequest(); requestFrame.data={fightID=fightID,sender=sender,trp=trp}; requestFrame.accept:Show(); requestFrame.decline:Show(); requestFrame.body:SetText((trp or sender).." wants to start a combat encounter with you.\n\nAccept to begin the initiative roll."); requestFrame.title:SetText("COMBAT REQUEST"); requestFrame:Show(); requestFrame:Raise() end
function RPAL.ShowCombatNotice(title,body) EnsureRequest(); requestFrame.title:SetText(title or "COMBAT"); requestFrame.body:SetText(body or ""); requestFrame.data=nil; requestFrame.accept:Hide(); requestFrame.decline:Hide(); requestFrame.noticeSerial=(requestFrame.noticeSerial or 0)+1; local serial=requestFrame.noticeSerial; requestFrame:Show(); requestFrame:Raise(); C_Timer.After(2.5,function() if requestFrame and requestFrame.noticeSerial==serial and requestFrame.data==nil then requestFrame:Hide() end end) end
function RPAL.OpenCombatUI() Build(); f:SetScale(GetCombatScale()); if RPAL.PrepareWindow then RPAL.PrepareWindow(f,nil,0.15)else f:Show();f:Raise()end; RPAL.RefreshCombatUI() end
function RPAL.CloseCombatUI() if f then f:Hide(); if f.logPanel then f.logPanel:Hide() end end end
function RPAL.ShowCombatJoinRequest(fightID,candidate,requester,trp) EnsureRequest(); requestFrame.accept:Show(); requestFrame.decline:Show(); requestFrame.title:SetText("JOIN REQUEST"); requestFrame.body:SetText((candidate or "A player").." wants to join the current combat.\n\nEvery current combatant must approve this request.\nThe invited player must also accept."); requestFrame.data={join=true,fightID=fightID,candidate=candidate,requester=requester}; requestFrame:Show(); requestFrame:Raise() end
function RPAL.ShowCombatJoinInvite(fightID,requester,trp) EnsureRequest(); requestFrame.accept:Show(); requestFrame.decline:Show(); requestFrame.title:SetText("COMBAT INVITE"); requestFrame.body:SetText((trp or requester).." wants to include you in a combat encounter."); requestFrame.data={invite=true,fightID=fightID,requester=requester}; requestFrame:Show(); requestFrame:Raise() end

function RPAL.RefreshCombatUI()
    Build()
    local s=C.GetState()
    if not s then f:Hide(); return end
    f:SetScale(GetCombatScale())
    f:Show()
    local list=ParticipantList(s); local n=#list
    local visualWinner=GetCombatVisualWinner(s,list)
    local cols=n<=4 and math.max(1,n) or 4
    local rows=math.max(1,math.ceil(n/cols))
    local w=math.max(620,math.min(1120,cols*232+(cols-1)*12+60))
    local screenHeight=(UIParent and UIParent.GetHeight and UIParent:GetHeight()) or 900
    local maximumHeight=math.max(520,math.floor(screenHeight/GetCombatScale())-70)
    local h=math.max(520,math.min(maximumHeight,210+math.min(rows,3)*246))
    f:SetSize(w,h)
    f.timelinePanel:SetWidth(w-60)
    f.timeline:SetWidth(w-80)
    f.timelineStatus:SetWidth(w-80)
    ApplyCombatTheme(visualWinner)
    ApplyCombatBackgroundForParticipant(visualWinner)
    local localName=LocalName()
    local finalWin=s.phase=="ended" and s.surrenderedBy and not (NormalizeUnitName(s.surrenderedBy)==NormalizeUnitName(localName))
    f.victoryText:SetShown(finalWin and true or false)
    f.finishedLog:SetShown(finalWin and true or false)
    f.finishedClose:SetShown(finalWin and true or false)
    f.title:SetShown(not finalWin);f.subtitle:SetShown(not finalWin);f.timelinePanel:SetShown(not finalWin);f.playerScroll:SetShown(not finalWin);f.logToggle:SetShown(not finalWin)
    if finalWin then
        for _,holder in ipairs(f.players or {}) do holder:Hide() end
        f.surrender:Hide()
        f:SetSize(620,300)
        f.victoryText:SetText("You won!")
        if f.logOpen then ClearLog(f.logPanel) end
        return
    end
    LayoutPlayers(list)
    f.surrender:ClearAllPoints()
    f.surrender:SetPoint("BOTTOM",f,"BOTTOM",0,12)
    f.surrender:SetShown(s.phase=="initiative" or s.phase=="turn" or s.phase=="defend")

    for i,item in ipairs(list) do
        local holder=f.players[i]; local card=holder.card; local accent=PanelAccent(item.p)
        card.name:SetText(item.p.trp or item.n)
        card._targetName=item.n
        SetupCombatModel(holder.model,item.n,item.p)
        card._selectable=(n>2 and s.phase=="turn" and s.attacker==LocalName() and item.n~=s.attacker)
        card.selectHint:SetShown(card._selectable and s.defender~=item.n)
        local cardRole="COMBATANT"
        if s.phase=="initiative" then
            cardRole=s.initiative[item.n] and "READY • INITIATIVE" or "WAITING FOR INITIATIVE"
        elseif s.phase=="turn" then
            local ready=false
            for _,attack in ipairs(s.roundAttacks or {}) do if attack.attacker==item.n then ready=true break end end
            if item.n==s.attacker then cardRole=(n==2 and "ATTACKER • CURRENT TURN" or "ATTACKER • CURRENT TURN") elseif n==2 and item.n==s.defender then cardRole="DEFENDER • VS "..NameOf(s,s.attacker) elseif ready then cardRole="READY • ACTION ROLLED" end
        elseif s.phase=="defend" then
            if item.n==s.defender then cardRole="DEFENDING • VS "..NameOf(s,s.attacker) else
                for _,attack in ipairs(s.defenseQueue or {}) do
                    if attack.target==item.n and attack.defense then cardRole="READY • DEFENSE ROLLED" break end
                end
            end
        end
        card.role:SetText(cardRole)
            card:SetBackdropColor(0.012,0.016,0.025,0.12)
        card:SetBackdropBorderColor(accent[1],accent[2],accent[3],0)
        card._baseBorder={accent[1],accent[2],accent[3],0}
        holder.model:SetAlpha(card._selectable and 1 or 0.92)

        holder.roll:Hide(); holder.pass:Hide(); holder.defend:Hide()
        if s._resolvingDefense or s._resolvingAction or s._awaitingHostTransition then holder.roll:Disable(); holder.pass:Disable(); holder.defend:Disable() else holder.roll:Enable(); holder.pass:Enable(); holder.defend:Enable() end
        local val,rollAnimKey
        if s.phase=="initiative" then
            val=s.initiative[item.n]
            if val then rollAnimKey="initiative:"..item.n..":"..tostring(val) end
        elseif s.phase=="turn" then
            for _,attack in ipairs(s.roundAttacks or {}) do
                if attack.attacker==item.n then val=attack.value;rollAnimKey="attack:"..tostring(attack.round or s.round)..":"..item.n..":"..tostring(val);break end
            end
        elseif s.phase=="defend" then
            for index,attack in ipairs(s.defenseQueue or {}) do
                if attack.target==item.n and attack.defense then val=attack.defense;rollAnimKey="defense:"..tostring(attack.round or s.round)..":"..tostring(index)..":"..item.n..":"..tostring(val);break end
            end
            -- In a 1v1 duel keep the already-revealed attack result visible
            -- while the defender rolls. Reuse the attack animation key so the
            -- attacker die never visibly rolls a second time on phase change.
            if not val and #list==2 then
                for _,attack in ipairs(s.roundAttacks or {}) do
                    if attack.attacker==item.n then val=attack.value;rollAnimKey="attack:"..tostring(attack.round or s.round)..":"..item.n..":"..tostring(val);break end
                end
            end
        end
        local isCritical=false
        if #list==2 then
            for _,attack in ipairs(s.roundAttacks or {}) do if attack.attacker==item.n and attack.value==20 and attack.critical then isCritical=true break end end
        end
        if val then AnimateRoll(card,val,rollAnimKey or ("roll:"..item.n..":"..tostring(val)),accent,isCritical) else ResetScore(card) end

        holder.roll:SetScript("OnClick",function()
            local state=C.GetState()
            if not state then return end
            if state.phase=="initiative" then
                if item.n==LocalName() then C.RollInitiative() end
            elseif state.phase=="turn" and item.n==state.attacker then
                if item.n==LocalName() then C.AttackRoll() end
            end
        end)
        holder.pass:SetScript("OnClick",function()
            local state=C.GetState(); if not state or state.phase~="turn" or item.n~=state.attacker then return end
            if item.n==LocalName() then C.PassAction() end
        end)
        holder.defend:SetScript("OnClick",function()
            local state=C.GetState()
            if not state or state.phase~="defend" or item.n~=state.defender then return end
            if item.n==LocalName() then C.DefendRoll() end
        end)

        if s.phase=="initiative" and not s.initiative[item.n] and item.n==LocalName() then holder.roll.text:SetText("ROLL INITIATIVE"); holder.roll:Show() end
        if s.phase=="turn" and item.n==s.attacker and item.n==LocalName() and s.defender then
            holder.roll.text:SetText("ROLL ACTION"); holder.roll:Show(); holder.pass:Show()
        end
        if s.phase=="defend" and item.n==s.defender and item.n==LocalName() then holder.defend.text:SetText("ROLL DEFENSE"); holder.defend:Show() end
    end

    -- Baldur's Gate 3-style turn strip: portrait + name + state, with the active actor highlighted.
    local roster=list
    if s.phase=="turn" and s.attackOrder and #s.attackOrder>0 then
        roster={}
        for _,name in ipairs(s.attackOrder) do if s.participants[name] then roster[#roster+1]=s.participants[name] and {n=name,p=s.participants[name]} end end
    elseif s.phase=="defend" and s.attackOrder and #s.attackOrder>0 then
        roster={}
        for _,name in ipairs(s.attackOrder) do if s.participants[name] then roster[#roster+1]={n=name,p=s.participants[name]} end end
    end
    local boxCount=#(f.timelineBoxes or {})
    if #roster>boxCount then
        local activeName=s.phase=="turn" and s.attacker or (s.phase=="defend" and s.defender or nil)
        if not activeName and s.phase=="initiative" then
            local localName=LocalName()
            if localName and not s.initiative[localName] then activeName=localName end
            if not activeName then
                for _,entry in ipairs(roster) do if not s.initiative[entry.n] then activeName=entry.n break end end
            end
        end
        local activeIndex=1
        for i,entry in ipairs(roster) do if entry.n==activeName then activeIndex=i break end end
        local first=math.max(1,math.min(activeIndex-math.floor(boxCount/2),#roster-boxCount+1))
        local visible={}
        for i=first,math.min(#roster,first+boxCount-1) do visible[#visible+1]=roster[i] end
        roster=visible
    end
    local boxW=math.min(104,math.max(72,math.floor((w-40)/math.max(1,math.min(#roster,boxCount)))))
    local shown=math.min(#roster,boxCount)
    local total=shown*boxW + math.max(0,shown-1)*4
    local sx=-(total-boxW)/2
    for i=1,boxCount do
        local b=f.timelineBoxes[i]
        if i<=shown then
            local item=roster[i]; local active=(s.phase=="initiative" and not s.initiative[item.n]) or (s.phase=="turn" and item.n==s.attacker) or (s.phase=="defend" and item.n==s.defender)
            b:SetWidth(boxW); b:ClearAllPoints(); b:SetPoint("LEFT",f.timelineRoster,"LEFT",math.max(0,(w-80-total)/2)+(i-1)*(boxW+4),0); b:Show()
            b.name:SetText(NameOf(s,item.n))
            local mark="WAITING"
            if s.phase=="initiative" and s.initiative[item.n] then mark="READY" end
            if s.phase=="turn" then
                local done=false; for _,attack in ipairs(s.roundAttacks or {}) do if attack.attacker==item.n then done=true break end end
                if done then mark="DONE" elseif active then mark="NOW" end
            elseif s.phase=="defend" then
                for _,attack in ipairs(s.defenseQueue or {}) do if attack.target==item.n and attack.defense then mark="DEF ✓" end end
                if active and mark~="DEF ✓" then mark="DEF NOW" end
            end
            b.mark:SetText(mark)
            local token=FindUnitToken(item.n)
            if token and SetPortraitTexture then
                local cp=item.p and item.p.cosmetics or {}
                local portraitSignature=table.concat({tostring(token),tostring(cp.portraitFrame or "Default"),tostring(item.p and item.p.cosmeticThemeHex or "")},"|")
                if b._portraitSignature~=portraitSignature then
                    b._portraitSignature=portraitSignature
                    b.portrait._rpalUnitToken=token
                    b.portrait:SetTexture("Interface\\CharacterFrame\\TempPortrait")
                    pcall(SetPortraitTexture,b.portrait,token)
                    if b._combatCosmeticFrame then b._combatCosmeticFrame:Hide(); b._combatCosmeticFrame:SetParent(nil); b._combatCosmeticFrame=nil end
                    local accent=HexColor(item.p and item.p.cosmeticThemeHex)
                    if RPAL.RenderCombatPortraitCosmetic and cp.portraitFrame and cp.portraitFrame~="Default" then
                        b._combatCosmeticFrame=RPAL.RenderCombatPortraitCosmetic(b,b.portrait,cp.portraitFrame,accent,42)
                    end
                end
                b.portrait:Show()
            else
                b._portraitSignature=nil
                b.portrait._rpalUnitToken=nil
                b.portrait:Hide()
            end
            if active then b:SetBackdropBorderColor(0.92,0.70,0.25,1); b:SetBackdropColor(0.10,0.075,0.025,0.98) else b:SetBackdropBorderColor(0.25,0.24,0.20,0.85); b:SetBackdropColor(0.02,0.025,0.035,0.92) end
        else b:Hide() end
    end

    if s.phase=="initiative" then
        f.phase:SetText("ROLL INITIATIVE  •  HIGHEST ROLL GOES FIRST")
    elseif s.phase=="turn" then
        f.phase:SetText(n==2 and "ATTACKER  →  DEFENDER  →  SWAP  •  1V1 FLOW" or "EVERY COMBATANT ATTACKS ONCE  •  THEN DEFENSES ARE RESOLVED")
    elseif s.phase=="defend" then
        f.phase:SetText(n==2 and "DEFEND AGAINST "..NameOf(s,s.attacker).."  •  THEN ROLES SWAP" or "DEFENSE QUEUE  •  DEFEND AGAINST THE ATTACKER SHOWN ABOVE")
    elseif s.phase=="ended" then
        local who=s.surrenderedBy and (s.participants[s.surrenderedBy] and s.participants[s.surrenderedBy].trp or s.surrenderedBy) or "A combatant"
        f.phase:SetText("COMBAT ENDED  •  "..who.." surrendered")
    end
    f.timeline:SetText(TimelineText(s))
    f.timelineStatus:SetText(TimelineStatusText(s))
    PlayDefenseReactionVisual(s)
    PlayCombatAttackVisual(s)
    f.surrender.text:SetText("SURRENDER / LEAVE")
    if f.logOpen then ClearLog(f.logPanel) end
end
