import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
  INITIAL_PARTY,
  RAID_PARTY,
  DEFAULT_GROUP_HEAL_TARGETS,
  groupHealTargets,
  partyDamageOutput,
  tankPressureTargets,
  type CombatLogEntry,
  type PartyMember,
  type Spell,
} from '../game'
import { completeRoguelike, type DungeonReward } from '../profile'
import type { Ability, CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository'
import { ControllerBindingLabel } from './ControllerIcons'
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
import {
  DualScreenTopCombat,
  useDualScreen,
  useDualScreenPublisher,
  type DualScreenCombatState,
} from '../dualScreen'
import {
  loadPvpRoguelikeCheckpoint,
  cancelPvpQueue,
  checkPvpQueue,
  joinPvpQueue,
  loadPvpMatch,
  publishPvpMatchState,
  randomCpuDifficulty,
  recordCpuPvpLeaderboard,
  recordPvpRoguelikeCheckpoint,
  submitPvpUpgradeChoice,
  type CpuDifficulty,
  type PvpMatchSnapshot,
  type PvpMatchSide,
  type PvpContentType,
  type PvpUpgradeChoicePayload,
} from '../pvpRoguelike'

const TICK_MS = 700
const UPGRADE_CHOICE_SECONDS = 10

type BossMechanic =
  | 'party-pulse'
  | 'searing-mark'
  | 'max-health-cut'
  | 'healing-reduction'
  | 'ramping-poison'

type PvpEncounter = DungeonEncounter & {
  bossMechanics?: BossMechanic[]
  sourceEncounterId?: number
}

type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
type AbilityLabelMode = 'ability' | 'slot'

type SelfBuffId =
  | `slot${SlotKey}-extra-target`
  | `slot${SlotKey}-cost-down`
  | `slot${SlotKey}-cooldown-down`
  | 'fifth-cast-free'
  | 'group-heal-boost'
  | 'shield-boost'

type OpponentDebuffId =
  | `opp-slot${SlotKey}-cost-up`
  | `opp-slot${SlotKey}-cooldown-up`
  | 'opp-takes-more-damage'
  | 'opp-healing-reduced'
  | 'opp-resource-regen-down'
  | 'opp-cleanse-cooldown-up'
  | 'opp-purge-random-buff'

type Choice<T extends string> = {
  id: T
  name: string
  description: string
}

type SideState = {
  party: PartyMember[]
  resource: number
  cooldowns: Record<string, number>
  enemyHealth: number
  buffs: SelfBuffId[]
  debuffs: OpponentDebuffId[]
  castsTowardFree: number
  freeCastReady: boolean
}

type FloatingCombatText = {
  id: number
  memberId: string
  side: 'player' | 'cpu'
  value: number
}

type PvpRunSummary = {
  bossesKilled: number
  experienceGained: number
  previousLevel: number | null
  newLevel: number | null
  levelsGained: number
  talentPointsGained: number
  unlockedAbilities: DungeonReward['unlockedAbilities']
  loot: Array<NonNullable<DungeonReward['bonusItem']>>
}

type LivePvpMatch = {
  id: string
  side: PvpMatchSide
  opponentSide: PvpMatchSide
  opponentName: string
  opponentClassName: string
}

const BOSS_MECHANICS: BossMechanic[] = [
  'party-pulse',
  'searing-mark',
  'max-health-cut',
  'healing-reduction',
  'ramping-poison',
]

const CPU_BEHAVIOR: Record<CpuDifficulty, {
  actionEveryTicks: number
  mistakeChance: number
  directHealThreshold: number
  groupHealThreshold: number
  hotThreshold: number
  shieldThreshold: number
}> = {
  1: { actionEveryTicks: 4, mistakeChance: 0.35, directHealThreshold: 0.52, groupHealThreshold: 0.48, hotThreshold: 0.58, shieldThreshold: 0.45 },
  2: { actionEveryTicks: 3, mistakeChance: 0.24, directHealThreshold: 0.58, groupHealThreshold: 0.55, hotThreshold: 0.66, shieldThreshold: 0.5 },
  3: { actionEveryTicks: 3, mistakeChance: 0.16, directHealThreshold: 0.64, groupHealThreshold: 0.6, hotThreshold: 0.72, shieldThreshold: 0.56 },
  4: { actionEveryTicks: 2, mistakeChance: 0.08, directHealThreshold: 0.7, groupHealThreshold: 0.66, hotThreshold: 0.78, shieldThreshold: 0.62 },
  5: { actionEveryTicks: 2, mistakeChance: 0.03, directHealThreshold: 0.76, groupHealThreshold: 0.72, hotThreshold: 0.82, shieldThreshold: 0.68 },
}

function clamp(value: number, min: number, max: number) {
  return Math.min(max, Math.max(min, value))
}

function chooseRandom<T>(items: T[], count: number) {
  const pool = [...items]
  const result: T[] = []
  while (pool.length > 0 && result.length < count) {
    result.push(pool.splice(Math.floor(Math.random() * pool.length), 1)[0])
  }
  return result
}

function formatEffectTime(ticks: number) {
  const seconds = (ticks * TICK_MS) / 1000
  return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(1)}s`
}

function createEmptyPvpRunSummary(): PvpRunSummary {
  return {
    bossesKilled: 0,
    experienceGained: 0,
    previousLevel: null,
    newLevel: null,
    levelsGained: 0,
    talentPointsGained: 0,
    unlockedAbilities: [],
    loot: [],
  }
}

function buffStacks<T extends string>(items: T[], id: T) {
  return items.filter((item) => item === id).length
}

function slotLabel(slot: SlotKey, spells: Spell[], labelMode: AbilityLabelMode) {
  const spell = spells.find((candidate) => candidate.key === slot)
  if (labelMode === 'ability' && spell) return spell.name
  return `Slot ${slot}`
}

function buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
  const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
    const label = slotLabel(slot, spells, labelMode)
    return [
      {
        id: `slot${slot}-extra-target` as SelfBuffId,
        name: `${label}: +1 target`,
        description: `${label} affects 1 additional ally when possible.`,
      },
      {
        id: `slot${slot}-cost-down` as SelfBuffId,
        name: `${label}: -25% cost`,
        description: `${label} costs 25% less resource.`,
      },
      {
        id: `slot${slot}-cooldown-down` as SelfBuffId,
        name: `${label}: -25% cooldown`,
        description: `${label} recharges 25% faster.`,
      },
    ]
  })
  return [
    ...slotChoices,
    { id: 'fifth-cast-free', name: 'Stored Momentum', description: 'After 5 casts, the next cast is free.' },
    { id: 'group-heal-boost', name: 'Wide Radiance', description: 'Party healing is 25% stronger.' },
    { id: 'shield-boost', name: 'Dense Shields', description: 'Shield absorbs are 25% stronger.' },
  ]
}

function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
  const slotChoices = (['1', '2', '3', '4', '5', '6'] as SlotKey[]).flatMap((slot) => {
    const label = slotLabel(slot, spells, labelMode)
    return [
      {
        id: `opp-slot${slot}-cost-up` as OpponentDebuffId,
        name: `${label}: +25% cost`,
        description: `Opponent ${label.toLowerCase()} costs 25% more resource.`,
      },
      {
        id: `opp-slot${slot}-cooldown-up` as OpponentDebuffId,
        name: `${label}: +25% cooldown`,
        description: `Opponent ${label.toLowerCase()} recharges 25% slower.`,
      },
    ]
  })
  return [
    ...slotChoices,
    { id: 'opp-takes-more-damage', name: 'Expose Weakness', description: 'Opponent takes 10% more damage.' },
    { id: 'opp-healing-reduced', name: 'Blunted Recovery', description: 'Opponent healing is 15% weaker.' },
    { id: 'opp-resource-regen-down', name: 'Mana Squeeze', description: 'Opponent resource regeneration is reduced by 25%.' },
    { id: 'opp-cleanse-cooldown-up', name: 'Lingering Toxins', description: 'Opponent cleanse cooldown is 25% longer.' },
    { id: 'opp-purge-random-buff', name: 'Strip Momentum', description: 'Remove 1 random buff from the opponent immediately.' },
  ]
}

function toCombatSpell(ability: Ability, key: string): Spell {
  const kinds: Record<string, Spell['kind']> = {
    direct_heal: 'direct',
    heal_over_time: 'hot',
    party_heal: 'group',
    absorb: 'shield',
    cleanse: 'cleanse',
  }
  return {
    id: String(ability.id),
    key,
    name: ability.name,
    description: ability.description,
    cost: ability.cost,
    cooldown: ability.cooldown,
    power: ability.power,
    glyph: ability.glyph,
    kind: kinds[ability.spellType] ?? 'direct',
  }
}

function effectiveMaxHealth(member: PartyMember) {
  return Math.max(1, Math.round(member.maxHealth * (member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 ? 0.75 : 1)))
}

function incomingDamageMultiplier(debuffs: OpponentDebuffId[]) {
  return 1.1 ** buffStacks(debuffs, 'opp-takes-more-damage')
}

function outgoingHealMultiplier(debuffs: OpponentDebuffId[]) {
  return 0.85 ** buffStacks(debuffs, 'opp-healing-reduced')
}

function healAmount(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
  const healingReduction = member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1
  return Math.round(amount * healingReduction * outgoingHealMultiplier(debuffs) * multiplier)
}

function healMember(member: PartyMember, amount: number, debuffs: OpponentDebuffId[], multiplier = 1) {
  return clamp(member.health + healAmount(member, amount, debuffs, multiplier), 0, effectiveMaxHealth(member))
}

function cooldownMultiplier(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[]) {
  const slot = spell.key as SlotKey
  const downStacks = buffStacks(buffs, `slot${slot}-cooldown-down` as SelfBuffId)
  const upStacks = buffStacks(debuffs, `opp-slot${slot}-cooldown-up` as OpponentDebuffId)
  const cleansePenalty = spell.kind === 'cleanse' ? buffStacks(debuffs, 'opp-cleanse-cooldown-up') : 0
  return (0.75 ** downStacks) * (1.25 ** (upStacks + cleansePenalty))
}

function spellResourceCost(spell: Spell, buffs: SelfBuffId[], debuffs: OpponentDebuffId[], freeCastReady: boolean) {
  const slot = spell.key as SlotKey
  const downStacks = buffStacks(buffs, `slot${slot}-cost-down` as SelfBuffId)
  const upStacks = buffStacks(debuffs, `opp-slot${slot}-cost-up` as OpponentDebuffId)
  const adjustedCost = Math.ceil(spell.cost * (0.75 ** downStacks) * (1.25 ** upStacks))
  return freeCastReady && buffStacks(buffs, 'fifth-cast-free') > 0 ? 0 : adjustedCost
}

function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
  const encounterThreat = (encounter: DungeonEncounter) => (
    encounter.maxHealth
    + encounter.damage * 18
    + encounter.tankDamage * 10
    + encounter.partyDamage * 18
  )
  const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
    .sort((left, right) => encounterThreat(left) - encounterThreat(right))
  const bossPool = [...pool.filter((encounter) => encounter.isBoss)]
    .sort((left, right) => encounterThreat(left) - encounterThreat(right))
  const trashCandidateCount = Math.min(trashPool.length, 5 + stage * (kind === 'raid' ? 1 : 2))
  const bossCandidateCount = Math.min(bossPool.length, 2 + Math.floor((stage + 1) / 2))
  const selectedTrash = chooseRandom(trashPool.slice(0, trashCandidateCount), 2)
  const selectedBoss = chooseRandom(bossPool.slice(0, bossCandidateCount), 1)[0] ?? bossPool[0] ?? trashPool[0] ?? pool[0]
  const healthScale = 0.75 + stage * (kind === 'raid' ? 0.28 : 0.22)
  const damageScale = 0.8 + stage * (kind === 'raid' ? 0.18 : 0.14)
  const mechanics = chooseRandom(BOSS_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
  return [...selectedTrash, selectedBoss].map((encounter, index) => {
    const isBoss = index === 2
    return {
      ...encounter,
      sourceEncounterId: encounter.id,
      id: 910000 + stage * 10 + index,
      sequence: (stage - 1) * 3 + index + 1,
      isBoss,
      encounterType: isBoss ? 'boss' : 'trash',
      enemyName: isBoss ? `${encounter.enemyName} ${stage}` : encounter.enemyName,
      description: isBoss
        ? `PvP boss with ${mechanics.join(', ')}.`
        : encounter.description,
      maxHealth: Math.round(encounter.maxHealth * healthScale),
      damage: Math.round(encounter.damage * damageScale),
      tankDamage: Math.round(encounter.tankDamage * damageScale),
      partyDamage: Math.round(encounter.partyDamage * (0.85 + stage * 0.04)),
      lootTables: [],
      bossMechanics: isBoss ? mechanics : [],
    }
  })
}

function starterSide(partyTemplate: PartyMember[], maxResource: number): SideState {
  return {
    party: partyTemplate.map((member) => ({ ...member })),
    resource: maxResource,
    cooldowns: {},
    enemyHealth: 0,
    buffs: [],
    debuffs: [],
    castsTowardFree: 0,
    freeCastReady: false,
  }
}

function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
  if (buff.id === 'fifth-cast-free') return 8
  if (buff.id === 'group-heal-boost') return 8
  if (buff.id === 'shield-boost') return 6
  const slot = buff.id.match(/slot([1-6])/i)?.[1] as SlotKey | undefined
  const spell = spells.find((candidate) => candidate.key === slot)
  if (!spell) return 5
  if (buff.id.endsWith('extra-target')) {
    if (spell.kind === 'group') return 2
    if (spell.kind === 'cleanse') return 7
    return spell.kind === 'shield' ? 6 : 8
  }
  if (buff.id.endsWith('cost-down')) return spell.cost >= 10 ? 8 : 6
  return spell.cooldown >= 5 ? 8 : 6
}

function scoreDebuff(debuff: Choice<OpponentDebuffId>, opponentBuffCount: number) {
  if (debuff.id === 'opp-takes-more-damage') return 9
  if (debuff.id === 'opp-healing-reduced') return 8
  if (debuff.id === 'opp-resource-regen-down') return 7
  if (debuff.id === 'opp-cleanse-cooldown-up') return 5
  if (debuff.id === 'opp-purge-random-buff') return opponentBuffCount > 0 ? 8 : 2
  if (debuff.id.endsWith('cost-up')) return 7
  return 6
}

function selectCpuChoice<T extends string>(
  choices: Array<Choice<T>>,
  skill: CpuDifficulty,
  score: (choice: Choice<T>) => number,
) {
  const ranked = [...choices].sort((left, right) => score(right) - score(left))
  if (skill <= 2) return ranked[Math.floor(Math.random() * ranked.length)]
  if (skill === 3) return ranked[Math.floor(Math.random() * Math.min(2, ranked.length))]
  if (skill === 4) return ranked[Math.floor(Math.random() * Math.min(1, ranked.length))]
  return ranked[0]
}

function removeRandomBuff(side: SideState) {
  if (side.buffs.length === 0) return side
  const nextBuffs = [...side.buffs]
  nextBuffs.splice(Math.floor(Math.random() * nextBuffs.length), 1)
  return { ...side, buffs: nextBuffs }
}

function createLogEntry(nextLogId: { current: number }, text: string, tone: CombatLogEntry['tone']) {
  return { id: nextLogId.current++, text, tone }
}

function summarizeStacks<T extends string>(items: T[], catalog: Array<Choice<T>>) {
  const counts = new Map<T, number>()
  items.forEach((item) => counts.set(item, (counts.get(item) ?? 0) + 1))
  return Array.from(counts.entries())
    .map(([id, count]) => {
      const label = catalog.find((choice) => choice.id === id)?.name ?? id
      return count > 1 ? `${label} x${count}` : label
    })
    .join(', ')
}

export function PvPRoguelikeScreen({
  profile,
  gameMode,
  contentType,
  encounterPool,
  onExit,
  onProfileUpdated,
}: {
  profile: CharacterProfile
  gameMode: GameMode
  contentType: PvpContentType
  encounterPool: DungeonEncounter[]
  onExit: () => void
  onProfileUpdated: (profile: CharacterProfile) => void
}) {
  const gameClass = profile.classes.find((candidate) => candidate.id === profile.character.classId)!
  const starterSpells = useMemo(() => gameClass.spells
    .filter((spell) => spell.unlockLevel === 1)
    .slice(0, 6)
    .map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
  const [abilityLabelMode] = useState<AbilityLabelMode>('ability')
  const selfBuffChoicesCatalog = useMemo(
    () => buildSelfBuffChoices(starterSpells, abilityLabelMode),
    [abilityLabelMode, starterSpells],
  )
  const opponentDebuffChoicesCatalog = useMemo(
    () => buildOpponentDebuffChoices(starterSpells, abilityLabelMode),
    [abilityLabelMode, starterSpells],
  )
  const [checkpointStage, setCheckpointStage] = useState(() =>
    loadPvpRoguelikeCheckpoint(profile.character.id, contentType),
  )
  const [startStage, setStartStage] = useState(checkpointStage)
  const maxResource = gameClass.maxResource
  const partyTemplate = useMemo(
    () => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
      ...member,
      name: member.id === 'mira' ? profile.character.name : member.name,
    })),
    [contentType, profile.character.name],
  )
  const cpuPartyTemplate = useMemo(
    () => (contentType === 'raid' ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
      ...member,
      name: member.id === 'mira' ? 'CPU Healer' : member.name,
    })),
    [contentType],
  )
  const [status, setStatus] = useState<'queueing' | 'playing' | 'upgrade-choice' | 'won' | 'lost'>('queueing')
  const [stage, setStage] = useState(startStage)
  const [encounters, setEncounters] = useState<PvpEncounter[]>(() => buildEncounterSegment(encounterPool, startStage, contentType))
  const [encounterIndex, setEncounterIndex] = useState(0)
  const [playerSide, setPlayerSide] = useState<SideState>(() => starterSide(partyTemplate, maxResource))
  const [cpuSide, setCpuSide] = useState<SideState>(() => starterSide(cpuPartyTemplate, maxResource))
  const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
  const selectedIdRef = useRef(partyTemplate[0].id)
  const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
  const [elapsedTicks, setElapsedTicks] = useState(0)
  const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
  const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null)
  const [liveUpgradePending, setLiveUpgradePending] = useState(false)
  const [queueMessage, setQueueMessage] = useState('')
  const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing opponent...', tone: 'system' }])
  const [reward, setReward] = useState<DungeonReward | null>(null)
  const [runSummary, setRunSummary] = useState<PvpRunSummary>(() => createEmptyPvpRunSummary())
  const [rewardError, setRewardError] = useState('')
  const [showEndLog, setShowEndLog] = useState(false)
  const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
  const [playerBuffChoices, setPlayerBuffChoices] = useState<Array<Choice<SelfBuffId>>>([])
  const [playerDebuffChoices, setPlayerDebuffChoices] = useState<Array<Choice<OpponentDebuffId>>>([])
  const [selectedBuff, setSelectedBuff] = useState<Choice<SelfBuffId> | null>(null)
  const [selectedDebuff, setSelectedDebuff] = useState<Choice<OpponentDebuffId> | null>(null)
  const [upgradeTimeLeft, setUpgradeTimeLeft] = useState(UPGRADE_CHOICE_SECONDS)
  const [encountersCleared, setEncountersCleared] = useState(0)
  const [paused, setPaused] = useState(false)
  const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
  const nextLogId = useRef(2)
  const nextFloatingTextId = useRef(1)
  const recordedRunRef = useRef(false)
  const rewardClaimedRef = useRef(false)
  const bossRewardClaimedRef = useRef(new Set<number>())
  const cpuDefeatedRef = useRef(false)
  const playerClearedEncounterRef = useRef(-1)
  const queuedMatchRef = useRef(false)
  const upgradeChoiceEndsAtRef = useRef(0)
  const autoSubmittedUpgradeRef = useRef(false)
  const liveMatchRef = useRef<LivePvpMatch | null>(null)
  const loggedOpponentDoneRef = useRef(false)
  const pendingLiveUpgradeRef = useRef<{
    encounterIndex: number
    buff: Choice<SelfBuffId>
    debuff: Choice<OpponentDebuffId>
  } | null>(null)
  const encounterPoolRef = useRef(encounterPool)
  const playerRef = useRef(playerSide)
  const cpuRef = useRef(cpuSide)
  const encounter = encounters[encounterIndex]
  const rewardDungeon = useMemo(
    () => profile.dungeons.find((candidate) => candidate.contentType === contentType) ?? profile.dungeons[0],
    [contentType, profile.dungeons],
  )
  const rewardDifficulty = rewardDungeon.difficulties[0]
  const finalEncountersCleared = status === 'won'
    ? Math.max(encountersCleared, encounterIndex + 1)
    : encountersCleared
  const cpuBehavior = cpuDifficulty ? CPU_BEHAVIOR[cpuDifficulty] : CPU_BEHAVIOR[1]
  const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
  const activeSpellEffects = useMemo(
    () => new Set(
      gameClass.talents
        .filter((talent) => talent.rank > 0)
        .map((talent) => talent.effectType),
    ),
    [gameClass.talents],
  )
  const playerDone = playerSide.enemyHealth <= 0
  const cpuDone = cpuSide.enemyHealth <= 0
  const playerAlive = playerSide.party.some((member) => member.health > 0)
  const cpuAlive = cpuSide.party.some((member) => member.health > 0)
  const partyColumns = contentType === 'raid' ? 6 : 3
  const {
    bindings,
    controllerIconStyle,
    directPartyTargeting,
    lastDevice,
  } = useInput()
  const {
    enabled: dualScreenEnabled,
  } = useDualScreen()

  const setSelectedTargetId = useCallback((id: string) => {
    selectedIdRef.current = id
    setSelectedId(id)
  }, [])

  const addLog = useCallback((text: string, tone: CombatLogEntry['tone']) => {
    setLog((current) => [createLogEntry(nextLogId, text, tone), ...current].slice(0, 60))
  }, [])

  const addFloatingHeal = useCallback((side: 'player' | 'cpu', memberId: string, value: number) => {
    if (value <= 0) return
    const id = nextFloatingTextId.current++
    setFloatingTexts((current) => [...current, { id, side, memberId, value }])
    window.setTimeout(() => {
      setFloatingTexts((current) => current.filter((entry) => entry.id !== id))
    }, 900)
  }, [])

  useEffect(() => {
    if (queuedMatchRef.current) return
    const loadedCheckpoint = loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
    setCheckpointStage(loadedCheckpoint)
    setStartStage(loadedCheckpoint)
  }, [contentType, profile.character.id])

  useEffect(() => {
    encounterPoolRef.current = encounterPool
  }, [encounterPool])

  const awardBossReward = useCallback((encounterIndexValue: number) => {
    if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
    bossRewardClaimedRef.current.add(encounterIndexValue)
    const rewardEncounter = encounters[encounterIndexValue]
    completeRoguelike(
      rewardDungeon.id,
      rewardDifficulty.id,
      0,
      0,
      Math.max(1, Math.round((elapsedTicks * TICK_MS) / 1000)),
      {
        bossesCleared: 1,
        experienceMode: 'pvp-boss-quarter-level',
        lootSourceEncounterId: rewardEncounter?.sourceEncounterId,
        roguelikeStage: stage,
      },
    )
      .then((result) => {
        setReward(result)
        setRunSummary((current) => {
          const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
          result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
          return {
            bossesKilled: current.bossesKilled + 1,
            experienceGained: current.experienceGained + result.experienceGained,
            previousLevel: current.previousLevel ?? result.previousLevel,
            newLevel: result.newLevel,
            levelsGained: current.levelsGained + result.levelsGained,
            talentPointsGained: current.talentPointsGained + result.talentPointsGained,
            unlockedAbilities: Array.from(unlockedById.values()),
            loot: result.bonusItem ? [...current.loot, result.bonusItem] : current.loot,
          }
        })
        onProfileUpdated(result.profile)
        if (result.bonusItem) {
          addLog(
            `${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
            'loot',
          )
        }
      })
      .catch((reason: unknown) => {
        setRewardError(
          reason instanceof Error ? reason.message : 'Unable to award roguelike experience.',
        )
      })
  }, [addLog, elapsedTicks, encounters, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id, stage])

  const finishRoguelikeRun = useCallback(() => {
    if (rewardClaimedRef.current) return
    rewardClaimedRef.current = true
  }, [])

  useEffect(() => {
    setPlayerBuffChoices((current) => current
      .map((choice) => selfBuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
      .filter((choice): choice is Choice<SelfBuffId> => Boolean(choice)))
    setPlayerDebuffChoices((current) => current
      .map((choice) => opponentDebuffChoicesCatalog.find((candidate) => candidate.id === choice.id))
      .filter((choice): choice is Choice<OpponentDebuffId> => Boolean(choice)))
    setSelectedBuff((current) => current
      ? selfBuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current
      : null)
    setSelectedDebuff((current) => current
      ? opponentDebuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current
      : null)
  }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])

  const startMatch = useCallback((nextStartStage?: number) => {
    const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
    const firstSegment = buildEncounterSegment(encounterPoolRef.current, matchStartStage, contentType)
    const firstEncounter = firstSegment[0]
    const basePlayer = starterSide(partyTemplate, maxResource)
    const baseCpu = starterSide(cpuPartyTemplate, maxResource)
    basePlayer.enemyHealth = firstEncounter.maxHealth
    baseCpu.enemyHealth = firstEncounter.maxHealth
    playerRef.current = basePlayer
    cpuRef.current = baseCpu
    nextLogId.current = 2
    playerClearedEncounterRef.current = -1
    queuedMatchRef.current = true
    bossRewardClaimedRef.current = new Set()
    setEncounters(firstSegment)
    setEncounterIndex(0)
    setCheckpointStage(matchStartStage)
    setStartStage(matchStartStage)
    setStage(matchStartStage)
    setElapsedTicks(0)
    setStatus('queueing')
    setPlayerSide(basePlayer)
    setCpuSide(baseCpu)
    setSelectedTargetId(partyTemplate[0].id)
    setPlayerBuffChoices([])
    setPlayerDebuffChoices([])
    setSelectedBuff(null)
    setSelectedDebuff(null)
    setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
    upgradeChoiceEndsAtRef.current = 0
    autoSubmittedUpgradeRef.current = false
    setEncountersCleared(0)
    setPaused(false)
    setTargetGroup(0)
    setReward(null)
    setRunSummary(createEmptyPvpRunSummary())
    setRewardError('')
    setShowEndLog(false)
    setFloatingTexts([])
    setCpuDifficulty(null)
    setLiveMatch(null)
    liveMatchRef.current = null
    setLiveUpgradePending(false)
    pendingLiveUpgradeRef.current = null
    loggedOpponentDoneRef.current = false
    recordedRunRef.current = false
    rewardClaimedRef.current = false
    cpuDefeatedRef.current = false
    const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
      liveMatchRef.current = null
      setLiveMatch(null)
      setCpuDifficulty(randomCpu)
      setQueueMessage(message)
      setLog([{ id: 1, text: message, tone: 'system' }])
      setStatus('playing')
      addLog(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`, 'system')
    }
    if (gameMode === 'offline') {
      const randomCpu = randomCpuDifficulty()
      const timer = window.setTimeout(() => {
        beginCpuMatch(randomCpu, `Offline mode. CPU ${randomCpu} enters at stage ${matchStartStage}.`)
      }, 500)
      return () => window.clearTimeout(timer)
    }

    let cancelled = false
    let ticketId = ''
    let pollTimer: number | undefined
    setQueueMessage(`Searching queue for 5s. Stage ${matchStartStage} start ready.`)
    setLog([{ id: 1, text: `Searching queue for 5s. Stage ${matchStartStage} start ready.`, tone: 'system' }])
    const beginLiveMatch = (match: PvpMatchSnapshot<SideState>, side: PvpMatchSide) => {
      if (cancelled) return
      const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
      const opponent = match.players[opponentSide]
      const nextLiveMatch = {
        id: match.id,
        side,
        opponentSide,
        opponentName: opponent.characterName,
        opponentClassName: opponent.className,
      }
      liveMatchRef.current = nextLiveMatch
      setLiveMatch(nextLiveMatch)
      setCpuDifficulty(null)
      const opponentBase = starterSide(
        cpuPartyTemplate.map((member) => ({
          ...member,
          name: member.id === 'mira' ? opponent.characterName : member.name,
        })),
        maxResource,
      )
      opponentBase.enemyHealth = firstEncounter.maxHealth
      cpuRef.current = opponentBase
      setCpuSide(opponentBase)
      setQueueMessage(`${opponent.characterName} found. Match begins.`)
      setLog([{ id: 1, text: `${opponent.characterName} found. Stage ${matchStartStage} begins.`, tone: 'system' }])
      setStatus('playing')
    }
    const fallbackTimer = window.setTimeout(() => {
      if (cancelled || liveMatchRef.current) return
      cancelled = true
      if (ticketId) cancelPvpQueue(ticketId).catch(() => undefined)
      const randomCpu = randomCpuDifficulty()
      beginCpuMatch(randomCpu, `No queued player found after 5s. CPU ${randomCpu} steps in.`)
    }, 5000)
    const pollQueue = () => {
      if (!ticketId || cancelled) return
      checkPvpQueue<SideState>(ticketId)
        .then((result) => {
          if (cancelled) return
          if (result.status === 'matched' && result.match && result.side) {
            window.clearTimeout(fallbackTimer)
            if (pollTimer) window.clearTimeout(pollTimer)
            beginLiveMatch(result.match, result.side)
            return
          }
          pollTimer = window.setTimeout(pollQueue, 500)
        })
        .catch(() => {
          if (!cancelled) pollTimer = window.setTimeout(pollQueue, 700)
        })
    }
    joinPvpQueue<SideState>(contentType, matchStartStage)
      .then((result) => {
        if (cancelled) return
        ticketId = result.ticketId
        if (result.status === 'matched' && result.match && result.side) {
          window.clearTimeout(fallbackTimer)
          beginLiveMatch(result.match, result.side)
          return
        }
        pollTimer = window.setTimeout(pollQueue, 500)
      })
      .catch(() => {
        if (cancelled) return
        window.clearTimeout(fallbackTimer)
        cancelled = true
        const randomCpu = randomCpuDifficulty()
        beginCpuMatch(randomCpu, `PvP server unavailable. CPU ${randomCpu} steps in.`)
      })
    return () => {
      cancelled = true
      window.clearTimeout(fallbackTimer)
      if (pollTimer) window.clearTimeout(pollTimer)
      if (ticketId && !liveMatchRef.current) cancelPvpQueue(ticketId).catch(() => undefined)
    }
  }, [addLog, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, setSelectedTargetId])

  useEffect(() => startMatch(), [startMatch])

  useEffect(() => {
    if (!liveMatch || status === 'queueing') return
    let stopped = false
    const syncMatch = () => {
      publishPvpMatchState<SideState>(liveMatch.id, {
        state: playerRef.current,
        status: status === 'upgrade-choice' ? 'upgrade-choice' : status,
        stage,
        encounterIndex,
        encountersCleared,
        enemyHealth: playerRef.current.enemyHealth,
        alive: playerRef.current.party.some((member) => member.health > 0),
        elapsedTicks,
      })
        .then((snapshot) => {
          if (stopped) return
          const opponentState = snapshot.states[liveMatch.opponentSide]
          if (opponentState) {
            cpuRef.current = opponentState
            setCpuSide(opponentState)
          }
          const opponentStatus = snapshot.statuses[liveMatch.opponentSide]
          if (opponentStatus === 'lost' && !loggedOpponentDoneRef.current) {
            loggedOpponentDoneRef.current = true
            cpuDefeatedRef.current = true
            addLog(`${liveMatch.opponentName} fell. Finish the boss for XP.`, 'loot')
          }
          if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') {
            finishRoguelikeRun()
            setStatus('lost')
            addLog(`${liveMatch.opponentName} finished first.`, 'danger')
          }
        })
        .catch(() => undefined)
    }
    syncMatch()
    const timer = window.setInterval(syncMatch, 700)
    return () => {
      stopped = true
      window.clearInterval(timer)
    }
  }, [addLog, encounterIndex, encountersCleared, elapsedTicks, finishRoguelikeRun, liveMatch, stage, status])

  const applySpell = useCallback((
    current: SideState,
    setCurrent: React.Dispatch<React.SetStateAction<SideState>>,
    sideName: 'player' | 'cpu',
    buffs: SelfBuffId[],
    debuffs: OpponentDebuffId[],
    spell: Spell,
    targetId: string,
  ) => {
    const effectiveCost = spellResourceCost(spell, buffs, debuffs, current.freeCastReady)
    if (current.resource < effectiveCost || (current.cooldowns[spell.id] ?? 0) > 0) return false
    const target = current.party.find((member) => member.id === targetId && member.health > 0)
    if (!target) return false
    const livingTargets = current.party.filter((member) => member.health > 0)
    const extraTarget = (blockedIds: string[]) => livingTargets
      .filter((member) => !blockedIds.includes(member.id))
      .sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
    const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
    const renewEffect = starterSpells.find((candidate) => candidate.kind === 'hot')
    const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield')
    const radianceEffect = starterSpells.find((candidate) => candidate.kind === 'group')
    const healingMultiplier = (member: PartyMember) =>
      hasSpellEffect('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
    const directTargets = new Set([targetId])
    const hotTargets = new Set(spell.kind === 'hot' ? [targetId] : [])
    const shieldTargets = new Set(spell.kind === 'shield' ? [targetId] : [])
    const extraTargets = buffStacks(buffs, `slot${spell.key as SlotKey}-extra-target` as SelfBuffId)
    const groupTargets = new Set(
      spell.kind === 'group'
        ? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
        : [],
    )
    for (let index = 0; index < extraTargets; index += 1) {
      if (spell.kind === 'group') break
      if (spell.kind === 'hot') {
        const extra = extraTarget([...hotTargets])
        if (extra) hotTargets.add(extra.id)
        continue
      }
      if (spell.kind === 'shield') {
        const extra = extraTarget([...shieldTargets])
        if (extra) shieldTargets.add(extra.id)
        continue
      }
      const extra = extraTarget([...directTargets])
      if (extra) directTargets.add(extra.id)
    }
    if (spell.kind === 'direct' && hasSpellEffect('mend_applies_renew') && renewEffect) {
      directTargets.forEach((id) => hotTargets.add(id))
    }
    if (spell.kind === 'direct' && hasSpellEffect('mend_applies_shield') && shieldEffect) {
      directTargets.forEach((id) => shieldTargets.add(id))
    }
    if (spell.kind === 'shield' && hasSpellEffect('shield_applies_renew') && renewEffect) {
      shieldTargets.forEach((id) => hotTargets.add(id))
    }
    const nextParty = current.party.map((member) => {
      if (member.health <= 0) return member
      if (spell.kind === 'group') {
        if (!groupTargets.has(member.id)) return member
        const groupPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'group-heal-boost')))
        const nextHealth = healMember(member, groupPower, debuffs, healingMultiplier(member))
        addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
        const nextShield = hasSpellEffect('radiance_applies_shield')
          ? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.3))
          : member.shield
        return {
          ...member,
          health: nextHealth,
          shield: nextShield,
          hotTicks: hasSpellEffect('radiance_applies_renew') && renewEffect
            ? Math.max(member.hotTicks, 3)
            : member.hotTicks,
        }
      }
      if (!directTargets.has(member.id) && !hotTargets.has(member.id) && !shieldTargets.has(member.id)) return member
      if (spell.kind === 'shield') {
        const shieldPower = Math.round(spell.power * (1.25 ** buffStacks(buffs, 'shield-boost')))
        return {
          ...member,
          shield: Math.max(member.shield, shieldPower),
          hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
        }
      }
      if (spell.kind === 'cleanse') {
        const nextHealth = healMember(member, spell.power, debuffs, healingMultiplier(member))
        addFloatingHeal(sideName, member.id, Math.max(0, nextHealth - member.health))
        return {
          ...member,
          health: nextHealth,
          debuff: undefined,
          debuffTicks: undefined,
          poisonStacks: undefined,
          maxHealthPenaltyTicks: undefined,
          healingReductionTicks: undefined,
        }
      }
      const nextHealth = directTargets.has(member.id)
        ? healMember(member, spell.power, debuffs, healingMultiplier(member))
        : member.health
      if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health)
      const nextShield = shieldTargets.has(member.id) && spell.kind === 'direct' && hasSpellEffect('mend_applies_shield')
        ? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5))
        : member.shield
      return {
        ...member,
        health: nextHealth,
        shield: nextShield,
        hotTicks: hotTargets.has(member.id) ? 5 : member.hotTicks,
      }
    })
    const freeCastStacks = buffStacks(buffs, 'fifth-cast-free')
    const nextFreeCastReady = freeCastStacks > 0 && current.freeCastReady ? false : current.freeCastReady
    const nextCastsTowardFree = freeCastStacks > 0
      ? current.freeCastReady
        ? 0
        : current.castsTowardFree + 1 >= 5
          ? 0
          : current.castsTowardFree + 1
      : current.castsTowardFree
    const gainedFreeCast = freeCastStacks > 0 && !current.freeCastReady && current.castsTowardFree + 1 >= 5
    const nextCooldowns = {
      ...current.cooldowns,
    }
    if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
      nextCooldowns[radianceEffect.id] = Math.max(0, (nextCooldowns[radianceEffect.id] ?? 0) - 2)
    }
    nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, buffs, debuffs)
    const nextState: SideState = {
      ...current,
      party: nextParty,
      resource: current.resource - effectiveCost,
      cooldowns: nextCooldowns,
      castsTowardFree: nextCastsTowardFree,
      freeCastReady: gainedFreeCast || nextFreeCastReady,
    }
    setCurrent(nextState)
    return true
  }, [activeSpellEffects, addFloatingHeal, starterSpells])

  const castPlayerSpell = useCallback((spell: Spell) => {
    if (status !== 'playing' || playerDone || !playerAlive) return
    const targetId = selectedIdRef.current
    const succeeded = applySpell(playerRef.current, (value) => {
      const next = typeof value === 'function' ? value(playerRef.current) : value
      playerRef.current = next
      setPlayerSide(next)
    }, 'player', playerRef.current.buffs, playerRef.current.debuffs, spell, targetId)
    if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
  }, [addLog, applySpell, playerAlive, playerDone, status])

  const selectRelativeTarget = useCallback((direction: -1 | 1) => {
    const living = playerRef.current.party.filter((member) => member.health > 0)
    if (living.length === 0) return
    const currentIndex = living.findIndex((member) => member.id === selectedIdRef.current)
    const nextIndex = currentIndex < 0
      ? 0
      : (currentIndex + direction + living.length) % living.length
    setSelectedTargetId(living[nextIndex].id)
  }, [setSelectedTargetId])

  const selectDirectionalTarget = useCallback((action: InputAction) => {
    const currentIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
    if (currentIndex < 0) {
      const firstLiving = playerRef.current.party.find((member) => member.health > 0)
      if (firstLiving) setSelectedTargetId(firstLiving.id)
      return
    }
    const currentRow = Math.floor(currentIndex / partyColumns)
    const currentColumn = currentIndex % partyColumns
    const candidates = playerRef.current.party
      .map((member, index) => ({
        member,
        index,
        row: Math.floor(index / partyColumns),
        column: index % partyColumns,
      }))
      .filter(({ member, index, row, column }) => {
        if (member.health <= 0 || index === currentIndex) return false
        if (action === 'navigateLeft') return row === currentRow && column < currentColumn
        if (action === 'navigateRight') return row === currentRow && column > currentColumn
        if (action === 'navigateUp') return row < currentRow
        return row > currentRow
      })
      .sort((a, b) => {
        const horizontal = action === 'navigateLeft' || action === 'navigateRight'
        const aPrimary = horizontal ? Math.abs(a.column - currentColumn) : Math.abs(a.row - currentRow)
        const bPrimary = horizontal ? Math.abs(b.column - currentColumn) : Math.abs(b.row - currentRow)
        const aSecondary = horizontal ? 0 : Math.abs(a.column - currentColumn)
        const bSecondary = horizontal ? 0 : Math.abs(b.column - currentColumn)
        return aPrimary - bPrimary || aSecondary - bSecondary
      })
    if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
  }, [partyColumns, setSelectedTargetId])

  const selectDirectTarget = useCallback((slot: number) => {
    const index = slot + (contentType === 'raid' ? targetGroup * 6 : 0)
    const member = playerRef.current.party[index]
    if (member?.health > 0) setSelectedTargetId(member.id)
  }, [contentType, setSelectedTargetId, targetGroup])

  const cpuTakeTurn = useCallback(() => {
    if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
    if (elapsedTicks % cpuBehavior.actionEveryTicks !== 0) return
    if (Math.random() < cpuBehavior.mistakeChance) return
    const side = cpuRef.current
    const spells = starterSpells
    const living = side.party.filter((member) => member.health > 0)
    const lowest = [...living].sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
    const tank = living.find((member) => member.role === 'Tank')
    const wounded = living.filter((member) => member.health / effectiveMaxHealth(member) < cpuBehavior.directHealThreshold)
    const averageHealth = living.reduce((total, member) => total + member.health / effectiveMaxHealth(member), 0) / Math.max(1, living.length)
    const cleanseTarget = living.find((member) => member.debuff || (member.poisonStacks ?? 0) > 0)
    const renewTarget = living.find((member) => member.hotTicks <= 1 && member.health / effectiveMaxHealth(member) < cpuBehavior.hotThreshold)
    const shieldTarget = living.find((member) => member.role === 'Tank' && member.shield <= 5 && member.health / effectiveMaxHealth(member) < cpuBehavior.shieldThreshold)
    const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [
      { spell: cleanseTarget ? spells.find((candidate) => candidate.kind === 'cleanse') : undefined, targetId: cleanseTarget?.id ?? null },
      { spell: averageHealth < cpuBehavior.groupHealThreshold ? spells.find((candidate) => candidate.kind === 'group') : undefined, targetId: lowest?.id ?? null },
      { spell: shieldTarget ? spells.find((candidate) => candidate.kind === 'shield') : undefined, targetId: shieldTarget?.id ?? null },
      { spell: wounded.length > 0 ? spells.find((candidate) => candidate.kind === 'direct') : undefined, targetId: lowest?.id ?? null },
      { spell: renewTarget ? spells.find((candidate) => candidate.kind === 'hot') : undefined, targetId: renewTarget?.id ?? null },
      { spell: tank ? spells.find((candidate) => candidate.kind === 'direct') : undefined, targetId: tank?.id ?? null },
    ]
    for (const action of ordered) {
      if (!action.spell || !action.targetId) continue
      const succeeded = applySpell(cpuRef.current, (value) => {
        const next = typeof value === 'function' ? value(cpuRef.current) : value
        cpuRef.current = next
        setCpuSide(next)
      }, 'cpu', cpuRef.current.buffs, cpuRef.current.debuffs, action.spell, action.targetId)
      if (succeeded) return
    }
  }, [applySpell, cpuAlive, cpuBehavior, cpuDifficulty, cpuDone, elapsedTicks, starterSpells, status])

  const advanceSide = useCallback((side: SideState, sideName: 'player' | 'cpu', encounterValue: PvpEncounter): SideState => {
    if (side.enemyHealth <= 0) return side
    const living = side.party.filter((member) => member.health > 0)
    if (living.length === 0) return side
    const primaryTarget = living[Math.floor(Math.random() * living.length)]
    const mechanics = encounterValue.bossMechanics ?? []
    const bossPulse = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 7 === 0 && mechanics.includes('party-pulse')
    const appliesDebuff = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 11 === 0 && mechanics.includes('searing-mark')
    const appliesMaxHealthCut = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 13 === 0 && mechanics.includes('max-health-cut')
    const appliesHealingReduction = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 9 === 0 && mechanics.includes('healing-reduction')
    const appliesPoison = encounterValue.isBoss && elapsedTicks > 0 && elapsedTicks % 12 === 0 && mechanics.includes('ramping-poison')
    const damageMultiplier = incomingDamageMultiplier(side.debuffs)
    const hasSpellEffect = (effectType: string) => sideName === 'player' && activeSpellEffects.has(effectType)
    const tankPressure = tankPressureTargets(side.party)
    const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
    const nextParty = side.party.map((member) => {
      if (member.health <= 0) return member
      let damage = member.id === primaryTarget.id ? encounterValue.damage : 0
      if (tankPressureIds.has(member.id)) {
        damage += Math.round(encounterValue.tankDamage * tankPressure.multiplier)
      }
      if (bossPulse) damage += 10
      if (member.debuff) damage += 6
      const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
        ? Math.max(1, (member.poisonStacks ?? 0) + 1)
        : member.poisonStacks ?? 0
      if (nextPoisonStacks > 0) damage += 3 + nextPoisonStacks * 3
      damage = Math.round(damage * damageMultiplier)
      if (member.shield > 0 && hasSpellEffect('shielded_damage_reduction')) {
        damage = Math.round(damage * 0.8)
      }
      const absorbed = Math.min(member.shield, damage)
      const healingMultiplier = member.shield > 0 && hasSpellEffect('shielded_healing_bonus') ? 1.2 : 1
      const healing = member.hotTicks > 0 ? healAmount(member, 6, side.debuffs, healingMultiplier) : 0
      if (healing > 0) addFloatingHeal(sideName, member.id, healing)
      const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
        ? 14
        : Math.max(0, (member.maxHealthPenaltyTicks ?? 0) - 1)
      const nextHealingReductionTicks = appliesHealingReduction && member.id === primaryTarget.id
        ? 14
        : Math.max(0, (member.healingReductionTicks ?? 0) - 1)
      const nextDebuffTicks = appliesDebuff && member.id === primaryTarget.id
        ? 8
        : Math.max(0, (member.debuffTicks ?? 0) - 1)
      return {
        ...member,
        health: clamp(
          member.health - damage + absorbed + healing,
          0,
          Math.max(1, Math.round(member.maxHealth * (nextMaxHealthPenaltyTicks > 0 ? 0.75 : 1))),
        ),
        shield: Math.max(0, member.shield - damage),
        hotTicks: Math.max(0, member.hotTicks - 1),
        debuff: nextDebuffTicks > 0
          ? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
          : undefined,
        debuffTicks: nextDebuffTicks > 0 ? nextDebuffTicks : undefined,
        poisonStacks: nextPoisonStacks,
        maxHealthPenaltyTicks: nextMaxHealthPenaltyTicks,
        healingReductionTicks: nextHealingReductionTicks,
      }
    })
    return {
      ...side,
      party: nextParty,
      resource: clamp(
        side.resource + 2.4 * (0.75 ** buffStacks(side.debuffs, 'opp-resource-regen-down')),
        0,
        maxResource,
      ),
      cooldowns: Object.fromEntries(
        Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]),
      ),
      enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
    }
  }, [activeSpellEffects, addFloatingHeal, elapsedTicks, maxResource])

  const beginUpgradePhase = useCallback(() => {
    upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
    autoSubmittedUpgradeRef.current = false
    setUpgradeTimeLeft(UPGRADE_CHOICE_SECONDS)
    setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
    setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
    setSelectedBuff(null)
    setSelectedDebuff(null)
    setStatus('upgrade-choice')
  }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])

  useEffect(() => {
    if (status !== 'playing' || paused || !encounter) return
    const timer = window.setInterval(() => {
      setElapsedTicks((value) => value + 1)
      if (!liveMatch) cpuTakeTurn()
      const nextPlayer = advanceSide(playerRef.current, 'player', encounter)
      const nextCpu = liveMatch ? cpuRef.current : advanceSide(cpuRef.current, 'cpu', encounter)
      if (nextPlayer.enemyHealth <= 0 && playerClearedEncounterRef.current !== encounterIndex) {
        playerClearedEncounterRef.current = encounterIndex
        setEncountersCleared((value) => value + 1)
        if (encounter.isBoss) {
          awardBossReward(encounterIndex)
          const nextCheckpoint = recordPvpRoguelikeCheckpoint(
            profile.character.id,
            contentType,
            stage,
          )
          if (nextCheckpoint > checkpointStage) {
            setCheckpointStage(nextCheckpoint)
            addLog(`Stage ${nextCheckpoint} checkpoint unlocked.`, 'loot')
          }
        }
      }
      playerRef.current = nextPlayer
      cpuRef.current = nextCpu
      setPlayerSide(nextPlayer)
      setCpuSide(nextCpu)

      const nextPlayerAlive = nextPlayer.party.some((member) => member.health > 0)
      const nextCpuAlive = nextCpu.party.some((member) => member.health > 0)
      if (!nextPlayerAlive) {
        finishRoguelikeRun()
        setStatus('lost')
        addLog('Your party fell first.', 'danger')
        return
      }
      if (!liveMatch && !nextCpuAlive && !cpuDefeatedRef.current) {
        cpuDefeatedRef.current = true
        addLog(`CPU ${cpuDifficulty ?? 1} fell. Finish the boss for XP.`, 'loot')
      }
      if (nextPlayer.enemyHealth <= 0) {
        if (encounter.isBoss && cpuDefeatedRef.current) {
          finishRoguelikeRun()
          setStatus('won')
          addLog(`${liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`} defeated. Match complete.`, 'loot')
          return
        }
        addLog(`${encounter.enemyName} cleared. Choose your next edge.`, 'loot')
        beginUpgradePhase()
      }
    }, TICK_MS / speedMultiplier)
    return () => window.clearInterval(timer)
  }, [addLog, advanceSide, awardBossReward, beginUpgradePhase, checkpointStage, contentType, cpuDifficulty, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, liveMatch, paused, profile.character.id, speedMultiplier, stage, status])

  useEffect(() => {
    if ((status !== 'won' && status !== 'lost') || recordedRunRef.current || !cpuDifficulty) return
    recordedRunRef.current = true
    recordCpuPvpLeaderboard({
      characterName: profile.character.name,
      className: profile.character.className,
      contentType,
      encountersCleared: finalEncountersCleared,
      cpuDifficulty,
      result: status === 'won' ? 'victory' : 'defeat',
      completedAt: new Date().toISOString(),
    })
  }, [contentType, cpuDifficulty, finalEncountersCleared, profile.character.className, profile.character.name, status])

  useEffect(() => {
    if (status !== 'upgrade-choice') return
    window.requestAnimationFrame(() => focusFirstControl())
  }, [status])

  useEffect(() => {
    if (!paused) return
    window.requestAnimationFrame(() => focusFirstControl())
  }, [paused])

  const confirmUpgradeChoices = useCallback((
    forcedBuff?: Choice<SelfBuffId>,
    forcedDebuff?: Choice<OpponentDebuffId>,
  ) => {
    const chosenBuff = forcedBuff ?? selectedBuff
    const chosenDebuff = forcedDebuff ?? selectedDebuff
    if (!chosenBuff || !chosenDebuff) return
    if (liveMatch) {
      const submittedBuff = chosenBuff
      const submittedDebuff = chosenDebuff
      const clearedEncounterIndex = encounterIndex
      const applyLiveUpgrade = (opponentChoice: PvpUpgradeChoicePayload) => {
        let nextPlayer = {
          ...playerRef.current,
          buffs: [...playerRef.current.buffs, submittedBuff.id],
        }
        if (opponentChoice.debuffId === 'opp-purge-random-buff') {
          nextPlayer = removeRandomBuff(nextPlayer)
        } else if (opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)) {
          nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, opponentChoice.debuffId as OpponentDebuffId] }
        }

        const clearedBoss = encounter.isBoss
        if (clearedBoss && cpuDefeatedRef.current) {
          finishRoguelikeRun()
          setStatus('won')
          setLiveUpgradePending(false)
          addLog(`${liveMatch.opponentName} defeated. Match complete.`, 'loot')
          return
        }
        const nextStage = clearedBoss ? stage + 1 : stage
        const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
        const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
        if (!nextEncounter) {
          finishRoguelikeRun()
          setStatus('won')
          setLiveUpgradePending(false)
          addLog('No further encounters remain.', 'loot')
          return
        }
        nextPlayer = {
          ...nextPlayer,
          party: nextPlayer.party.map((member) => ({
            ...member,
            health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth),
            debuff: undefined,
            debuffTicks: undefined,
            poisonStacks: undefined,
            maxHealthPenaltyTicks: undefined,
            healingReductionTicks: undefined,
          })),
          resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
          cooldowns: {},
          enemyHealth: nextEncounter.maxHealth,
        }
        if (clearedBoss) {
          setStage(nextStage)
          setEncounters((current) => [...current, ...nextSegment])
        }
        setEncounterIndex((value) => value + 1)
        setPlayerSide(nextPlayer)
        playerRef.current = nextPlayer
        setElapsedTicks(0)
        setLiveUpgradePending(false)
        pendingLiveUpgradeRef.current = null
        setStatus('playing')
        const opponentDebuff = opponentDebuffChoicesCatalog.find((choice) => choice.id === opponentChoice.debuffId)
        addLog(
          `You chose ${submittedBuff.name} and ${submittedDebuff.name}. ${liveMatch.opponentName} chose ${opponentDebuff?.name ?? 'an opponent debuff'}.`,
          'system',
        )
      }
      setLiveUpgradePending(true)
      pendingLiveUpgradeRef.current = {
        encounterIndex: clearedEncounterIndex,
        buff: submittedBuff,
        debuff: submittedDebuff,
      }
      addLog(`Waiting for ${liveMatch.opponentName} to choose.`, 'system')
      submitPvpUpgradeChoice(liveMatch.id, {
        encounterIndex: clearedEncounterIndex,
        buffId: submittedBuff.id,
        debuffId: submittedDebuff.id,
      }).catch((reason: unknown) => {
        setLiveUpgradePending(false)
        addLog(reason instanceof Error ? reason.message : 'Unable to submit PvP upgrade choice.', 'danger')
      })
      let attempts = 0
      const waitForOpponent = () => {
        attempts += 1
        loadPvpMatch<SideState>(liveMatch.id)
          .then((snapshot) => {
            const opponentChoice = snapshot.upgradeChoices[liveMatch.opponentSide]?.[String(clearedEncounterIndex)]
            if (opponentChoice) {
              applyLiveUpgrade(opponentChoice)
              return
            }
            if (attempts < 120 && pendingLiveUpgradeRef.current) {
              window.setTimeout(waitForOpponent, 500)
            }
          })
          .catch(() => {
            if (attempts < 120 && pendingLiveUpgradeRef.current) {
              window.setTimeout(waitForOpponent, 700)
            }
          })
      }
      window.setTimeout(waitForOpponent, 250)
      return
    }
    if (!cpuDifficulty) return
    const cpuBuffChoices = chooseRandom(selfBuffChoicesCatalog, 3)
    const cpuDebuffChoices = chooseRandom(opponentDebuffChoicesCatalog, 3)
    const cpuBuff = selectCpuChoice(cpuBuffChoices, cpuDifficulty, (choice) => scoreSelfBuff(choice, starterSpells))
    const cpuDebuff = selectCpuChoice(cpuDebuffChoices, cpuDifficulty, (choice) => scoreDebuff(choice, playerRef.current.buffs.length))

    let nextPlayer = {
      ...playerRef.current,
      buffs: [...playerRef.current.buffs, chosenBuff.id],
    }
    let nextCpu = {
      ...cpuRef.current,
      buffs: [...cpuRef.current.buffs, cpuBuff.id],
    }

    if (chosenDebuff.id === 'opp-purge-random-buff') {
      nextCpu = removeRandomBuff(nextCpu)
    } else {
      nextCpu = { ...nextCpu, debuffs: [...nextCpu.debuffs, chosenDebuff.id] }
    }

    if (cpuDebuff.id === 'opp-purge-random-buff') {
      nextPlayer = removeRandomBuff(nextPlayer)
    } else {
      nextPlayer = { ...nextPlayer, debuffs: [...nextPlayer.debuffs, cpuDebuff.id] }
    }

    const clearedBoss = encounter.isBoss
    if (clearedBoss && cpuDefeatedRef.current) {
      finishRoguelikeRun()
      setStatus('won')
      addLog('CPU defeated. Match complete.', 'loot')
      return
    }
    const nextStage = clearedBoss ? stage + 1 : stage
    const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
    const nextEncounter = clearedBoss ? nextSegment[0] : encounters[encounterIndex + 1]
    if (!nextEncounter) {
      finishRoguelikeRun()
      setStatus('won')
      addLog('No further encounters remain.', 'loot')
      return
    }

    nextPlayer = {
      ...nextPlayer,
      party: nextPlayer.party.map((member) => ({
        ...member,
        health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth),
        debuff: undefined,
        debuffTicks: undefined,
        poisonStacks: undefined,
        maxHealthPenaltyTicks: undefined,
        healingReductionTicks: undefined,
      })),
      resource: clamp(nextPlayer.resource + Math.round(maxResource * 0.25), 0, maxResource),
      cooldowns: {},
      enemyHealth: nextEncounter.maxHealth,
    }
    nextCpu = {
      ...nextCpu,
      party: nextCpu.party.map((member) => ({
        ...member,
        health: member.health <= 0 ? 0 : clamp(member.health + Math.round(member.maxHealth * 0.3), 0, member.maxHealth),
        debuff: undefined,
        debuffTicks: undefined,
        poisonStacks: undefined,
        maxHealthPenaltyTicks: undefined,
        healingReductionTicks: undefined,
      })),
      resource: clamp(nextCpu.resource + Math.round(maxResource * 0.25), 0, maxResource),
      cooldowns: {},
      enemyHealth: nextEncounter.maxHealth,
    }

    if (clearedBoss) {
      setStage(nextStage)
      setEncounters((current) => [...current, ...nextSegment])
    }
    setEncounterIndex((value) => value + 1)
    setPlayerSide(nextPlayer)
    setCpuSide(nextCpu)
    playerRef.current = nextPlayer
    cpuRef.current = nextCpu
    setElapsedTicks(0)
    setStatus('playing')
    addLog(`You chose ${chosenBuff.name} and ${chosenDebuff.name}. CPU ${cpuDifficulty} chose ${cpuBuff.name} and ${cpuDebuff.name}.`, 'system')
  }, [addLog, contentType, cpuDifficulty, encounter, encounterIndex, encounterPool, encounters, finishRoguelikeRun, liveMatch, maxResource, opponentDebuffChoicesCatalog, selectedBuff, selectedDebuff, selfBuffChoicesCatalog, stage, starterSpells])

  useEffect(() => {
    if (status !== 'upgrade-choice' || liveUpgradePending) return
    if (upgradeChoiceEndsAtRef.current <= 0) {
      upgradeChoiceEndsAtRef.current = Date.now() + UPGRADE_CHOICE_SECONDS * 1000
    }
    const updateTimer = () => {
      const remaining = Math.max(0, (upgradeChoiceEndsAtRef.current - Date.now()) / 1000)
      setUpgradeTimeLeft(remaining)
      if (remaining > 0 || autoSubmittedUpgradeRef.current) return
      autoSubmittedUpgradeRef.current = true
      const autoBuff = selectedBuff ?? playerBuffChoices[Math.floor(Math.random() * playerBuffChoices.length)]
      const autoDebuff = selectedDebuff ?? playerDebuffChoices[Math.floor(Math.random() * playerDebuffChoices.length)]
      if (autoBuff) setSelectedBuff(autoBuff)
      if (autoDebuff) setSelectedDebuff(autoDebuff)
      if (autoBuff && autoDebuff) {
        addLog('Upgrade timer expired. Random choices selected.', 'system')
        confirmUpgradeChoices(autoBuff, autoDebuff)
      }
    }
    updateTimer()
    const timer = window.setInterval(updateTimer, 100)
    return () => window.clearInterval(timer)
  }, [
    addLog,
    confirmUpgradeChoices,
    liveUpgradePending,
    playerBuffChoices,
    playerDebuffChoices,
    selectedBuff,
    selectedDebuff,
    status,
  ])

  useGameAction((action) => {
    if (action === 'toggleSpeed') {
      if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
      return
    }
    if (action === 'pause' || action === 'back') {
      if (status === 'playing') setPaused((value) => !value)
      return
    }
    if (paused || status !== 'playing') return
    if (action.startsWith('navigate')) {
      selectDirectionalTarget(action)
      return
    }
    if (action === 'previousTarget') {
      selectRelativeTarget(-1)
      return
    }
    if (action === 'nextTarget') {
      selectRelativeTarget(1)
      return
    }
    if (action.startsWith('targetParty')) {
      selectDirectTarget(Number(action.slice('targetParty'.length)) - 1)
      return
    }
    if (action === 'toggleTargetGroup') {
      if (contentType !== 'raid') return
      setTargetGroup((current) => {
        const groupCount = Math.max(1, Math.ceil(playerRef.current.party.length / 6))
        const next = ((current + 1) % groupCount) as 0 | 1 | 2
        const selectedIndex = playerRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
        const nextMember = playerRef.current.party[(selectedIndex < 0 ? 0 : selectedIndex % 6) + next * 6]
        if (nextMember?.health > 0) setSelectedTargetId(nextMember.id)
        return next
      })
      return
    }
    if (action.startsWith('ability')) {
      const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length))
      if (spell) castPlayerSpell(spell)
    }
  })

  const dualScreenState = useMemo<DualScreenCombatState>(() => ({
    difficultyName: `Stage ${stage}`,
    dungeonName: encounter.enemyName,
    contentName: 'PvP Roguelike',
    encounterName: encounter.enemyName,
    encounterDescription: encounter.description,
    encounterHealth: playerSide.enemyHealth,
    encounterMaxHealth: encounter.maxHealth,
    encounterIsBoss: encounter.isBoss,
    encounterIndex,
    encounterCount: encounters.length,
    party: playerSide.party,
    opponentName: opponentLabel,
    opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
    opponentParty: cpuSide.party,
    opponentResource: cpuSide.resource,
    opponentEnemyHealth: cpuSide.enemyHealth,
    opponentBuffSummary: cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
    opponentDebuffSummary: cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none',
    floatingTexts: floatingTexts
      .filter((entry) => entry.side === 'player')
      .map(({ id, memberId, value }) => ({ id, memberId, value })),
    partySize: playerSide.party.length,
    selectedId,
    log,
    status: status === 'queueing' ? 'playing' : status,
    resource: playerSide.resource,
    maxResource,
    resourceName: gameClass.resourceName,
    playerIsAlive: playerAlive,
    spells: starterSpells.map((spell, slotIndex) => ({
      ...spell,
      cost: spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady),
      slotIndex,
      remaining: playerSide.cooldowns[spell.id] ?? 0,
    })),
    activeDevice: lastDevice,
    bindings: bindings[lastDevice],
    controllerIconStyle,
    directPartyTargeting,
    paused,
    targetGroup,
    speedMultiplier,
  }), [
    bindings,
    controllerIconStyle,
    cpuDifficulty,
    cpuSide.buffs,
    cpuSide.debuffs,
    cpuSide.enemyHealth,
    cpuSide.party,
    cpuSide.resource,
    directPartyTargeting,
    encounter.description,
    encounter.enemyName,
    encounter.isBoss,
    encounter.maxHealth,
    encounterIndex,
    encounters.length,
    floatingTexts,
    gameClass.resourceName,
    lastDevice,
    liveMatch?.opponentClassName,
    log,
    maxResource,
    opponentDebuffChoicesCatalog,
    opponentLabel,
    selfBuffChoicesCatalog,
    paused,
    playerAlive,
    playerSide.buffs,
    playerSide.cooldowns,
    playerSide.debuffs,
    playerSide.enemyHealth,
    playerSide.freeCastReady,
    playerSide.party,
    playerSide.resource,
    selectedId,
    speedMultiplier,
    stage,
    starterSpells,
    status,
    targetGroup,
  ])
  useDualScreenPublisher(dualScreenState, dualScreenEnabled)

  return (
    <main
      className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
      data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
    >
      <section className="content-screen pvp-match-screen">
        {status === 'queueing' && (
          <div className="placeholder-panel">
            <div className="placeholder-runes">P V P</div>
            <p>{queueMessage}</p>
          </div>
        )}

        {dualScreenEnabled && status !== 'queueing' && (
          <DualScreenTopCombat
            state={dualScreenState}
            onCastSpell={castPlayerSpell}
            onSelectTarget={setSelectedTargetId}
          />
        )}

        {!dualScreenEnabled && status !== 'queueing' && (
          <div className="pvp-board">
            <section className="combat-panel pvp-side">
              <div className="encounter-header">
                <div>
                  <p className="eyebrow">You</p>
                  <h2>{profile.character.name}</h2>
                </div>
                <div className="resource-row pvp-resource-row">
                  <div className="pvp-resource-wrap">
                    <span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {maxResource}</span>
                    {speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
                    <div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / maxResource) * 100}%` }} /></div>
                  </div>
                </div>
              </div>
              <div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
                {playerSide.party.map((member) => (
                  <button
                    className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`}
                    key={`player-${member.id}`}
                    onClick={() => setSelectedTargetId(member.id)}
                    type="button"
                  >
                    <div className="member-header">
                      <span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
                      <strong>{member.name}</strong>
                      <small>{Math.floor(member.health)} / {effectiveMaxHealth(member)}</small>
                    </div>
                    <div className="bar member-health">
                      <span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
                      {member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
                    </div>
                    <div className="floating-combat-texts" aria-hidden="true">
                      {floatingTexts
                        .filter((entry) => entry.side === 'player' && entry.memberId === member.id)
                        .map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
                    </div>
                    <div className="member-effects">
                      {member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
                      {member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
                      {member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
                      {(member.poisonStacks ?? 0) > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
                      {(member.maxHealthPenaltyTicks ?? 0) > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}</span>}
                      {(member.healingReductionTicks ?? 0) > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}</span>}
                    </div>
                  </button>
                ))}
              </div>
              <p className="roguelike-upgrade-list">
                Buffs: {playerSide.buffs.length > 0 ? summarizeStacks(playerSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {playerSide.debuffs.length > 0 ? summarizeStacks(playerSide.debuffs, opponentDebuffChoicesCatalog) : 'none'}
              </p>
            </section>

            <section className="combat-panel pvp-middle-panel">
              <div className="encounter-header">
                <div>
                  <p className="eyebrow">Encounter {encounterIndex + 1}</p>
                  <h2>{encounter.enemyName}</h2>
                  <small>Stage {stage}{encounter.isBoss ? ' Boss' : ''}</small>
                </div>
              </div>
              <div className="pvp-enemy-race">
                <div>
                  <strong>Your clear</strong>
                  <div className="bar enemy-health boss-bar">
                    <span style={{ width: `${(playerSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
                  </div>
                  <small>{Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</small>
                </div>
                <div>
                  <strong>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'}</strong>
                  <div className="bar enemy-health boss-bar">
                    <span style={{ width: `${(cpuSide.enemyHealth / encounter.maxHealth) * 100}%` }} />
                  </div>
                  <small>{Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</small>
                </div>
              </div>
              <div className="spell-bar six-slots vertical-spell-bar pvp-vertical-spell-bar">
                {starterSpells.map((spell) => {
                  const remaining = playerSide.cooldowns[spell.id] ?? 0
                  const cost = spellResourceCost(spell, playerSide.buffs, playerSide.debuffs, playerSide.freeCastReady)
                  return (
                    <button
                      className="spell"
                      disabled={status !== 'playing' || playerDone || !playerAlive || remaining > 0 || playerSide.resource < cost}
                      key={`middle-${spell.id}`}
                      onClick={() => castPlayerSpell(spell)}
                      type="button"
                    >
                      <kbd>
                        <ControllerBindingLabel
                          binding={bindings[lastDevice][`ability${Number(spell.key)}` as InputAction]}
                          compact
                          iconStyle={controllerIconStyle}
                        />
                      </kbd>
                      <span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
                      <strong>{spell.name}</strong>
                      <small>{cost} {gameClass.resourceName}</small>
                      {remaining > 0 && <i>{remaining.toFixed(1)}</i>}
                    </button>
                  )
                })}
              </div>
              <p className="roguelike-upgrade-list">
                {liveMatch ? `${liveMatch.opponentName} (${liveMatch.opponentClassName})` : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}
              </p>
            </section>

            <section className="combat-panel pvp-side">
              <div className="encounter-header">
                <div>
                  <p className="eyebrow">Opponent</p>
                  <h2>{opponentLabel}</h2>
                </div>
                <div className="resource-row pvp-resource-row">
                  <div className="pvp-resource-wrap">
                    <span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
                    <div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / maxResource) * 100}%` }} /></div>
                  </div>
                </div>
              </div>
              <div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
                {cpuSide.party.map((member) => (
                  <div className={`party-member ${member.health <= 0 ? 'down' : ''}`} key={`cpu-${member.id}`}>
                    <div className="member-header">
                      <span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
                      <strong>{member.name}</strong>
                      <small>{Math.floor(member.health)} / {effectiveMaxHealth(member)}</small>
                    </div>
                    <div className="bar member-health">
                      <span style={{ width: `${(member.health / effectiveMaxHealth(member)) * 100}%` }} />
                      {member.shield > 0 && <i style={{ width: `${(member.shield / effectiveMaxHealth(member)) * 100}%` }} />}
                    </div>
                    <div className="floating-combat-texts" aria-hidden="true">
                      {floatingTexts
                        .filter((entry) => entry.side === 'cpu' && entry.memberId === member.id)
                        .map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
                    </div>
                    <div className="member-effects">
                      {member.hotTicks > 0 && <span className="buff">Renew {formatEffectTime(member.hotTicks)}</span>}
                      {member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
                      {member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
                      {(member.poisonStacks ?? 0) > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
                      {(member.maxHealthPenaltyTicks ?? 0) > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks ?? 0)}</span>}
                      {(member.healingReductionTicks ?? 0) > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks ?? 0)}</span>}
                    </div>
                  </div>
                ))}
              </div>
              <p className="roguelike-upgrade-list">
                Buffs: {cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none'} | Debuffs: {cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none'}
              </p>
            </section>
          </div>
        )}

        {status === 'upgrade-choice' && (
          <div className="result-screen">
            <div className="pvp-upgrade-dialog">
              <div className="pvp-upgrade-header">
                <div>
                  <p className="eyebrow">Choose Edge</p>
                  <h2>{encounter.isBoss ? `Stage ${stage} Boss Cleared` : `${encounter.enemyName} Cleared`}</h2>
                </div>
                <strong className={upgradeTimeLeft <= 3 ? 'danger' : ''}>{upgradeTimeLeft.toFixed(1)}s</strong>
              </div>
              <div className="pvp-choice-columns">
                <div>
                  <strong>Self Buff</strong>
                  <div className="upgrade-choice-grid">
                    {playerBuffChoices.map((choice) => (
                      <button
                        className={selectedBuff?.id === choice.id ? 'selected-upgrade' : ''}
                        key={choice.id}
                        onClick={() => setSelectedBuff(choice)}
                        type="button"
                      >
                        <strong>{choice.name}</strong>
                        <small>{choice.description}</small>
                      </button>
                    ))}
                  </div>
                </div>
                <div>
                  <strong>Opponent Debuff</strong>
                  <div className="upgrade-choice-grid">
                    {playerDebuffChoices.map((choice) => (
                      <button
                        className={selectedDebuff?.id === choice.id ? 'selected-upgrade' : ''}
                        key={choice.id}
                        onClick={() => setSelectedDebuff(choice)}
                        type="button"
                      >
                        <strong>{choice.name}</strong>
                        <small>{choice.description}</small>
                      </button>
                    ))}
                  </div>
                </div>
              </div>
              {liveUpgradePending && <p>Waiting for opponent choice...</p>}
              <button className="secondary-result-button" disabled={!selectedBuff || !selectedDebuff || liveUpgradePending} onClick={() => confirmUpgradeChoices()} type="button">
                {liveUpgradePending ? 'Waiting' : 'Continue'}
              </button>
            </div>
          </div>
        )}

        {paused && (
          <div className="pause-screen">
            <div>
              <p className="eyebrow">Paused</p>
              <h2>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
              <button onClick={() => setPaused(false)} type="button">Resume</button>
              <button className="secondary-result-button" onClick={onExit} type="button">Leave</button>
            </div>
          </div>
        )}

        {(status === 'won' || status === 'lost') && (
          <div className="result-screen">
            <div>
              <p className="eyebrow">{status === 'won' ? 'Victory' : 'Defeat'}</p>
              <h2>{status === 'won' ? `${opponentLabel} Falls` : `${opponentLabel} Wins`}</h2>
              <p>{finalEncountersCleared} encounters cleared.</p>
              <div className="reward-summary">
                <p>{runSummary.bossesKilled} bosses killed.</p>
                <p>+{runSummary.experienceGained} XP</p>
                {runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
                {rewardError && <p className="reward-error">{rewardError}</p>}
                {runSummary.levelsGained > 0 && runSummary.previousLevel !== null && runSummary.newLevel !== null && (
                  <p className="level-gain">
                    Level {runSummary.previousLevel} to {runSummary.newLevel}
                    <small>+{runSummary.talentPointsGained} talent point{runSummary.talentPointsGained === 1 ? '' : 's'}</small>
                  </p>
                )}
                {runSummary.unlockedAbilities.map((ability) => (
                  <p className="ability-unlock" key={ability.id}>
                    <span>{ability.glyph}</span>
                    Ability Unlocked: {ability.name}
                  </p>
                ))}
                <div className="run-loot-rolls">
                  {runSummary.loot.length > 0 ? runSummary.loot.map((item, index) => (
                    <div className="dropped" key={`${item.id}-${index}`}>
                      <strong>Boss {index + 1}</strong>
                      <span>
                        {item.glyph} {item.name} x{item.quantity}
                        {item.duplicate ? ` (owned x${item.quantityAfter})` : ''}
                      </span>
                    </div>
                  )) : (
                    <div>
                      <strong>Loot</strong>
                      <span>No boss loot awarded</span>
                    </div>
                  )}
                </div>
                {reward && runSummary.bossesKilled === 0 && (
                  <>
                    <p>+{reward.experienceGained} XP</p>
                    {reward.levelsGained > 0 && (
                      <p className="level-gain">
                        Level {reward.previousLevel} to {reward.newLevel}
                        <small>+{reward.talentPointsGained} talent point</small>
                      </p>
                    )}
                    {reward.unlockedAbilities.map((ability) => (
                      <p className="ability-unlock" key={ability.id}>
                        <span>{ability.glyph}</span>
                        Ability Unlocked: {ability.name}
                      </p>
                    ))}
                    {reward.bonusItem && (
                      <p className="ability-unlock">
                        <span>{reward.bonusItem.glyph}</span>
                        {reward.bonusItem.name} x{reward.bonusItem.quantity}
                        {reward.bonusItem.duplicate ? ` (owned x${reward.bonusItem.quantityAfter})` : ''}
                      </p>
                    )}
                  </>
                )}
              </div>
              {log.length > 0 && (
                <>
                  <button className="secondary-result-button" onClick={() => setShowEndLog((value) => !value)} type="button">
                    {showEndLog ? 'Hide Combat Log' : 'View Combat Log'}
                  </button>
                  {showEndLog && (
                    <div className="result-log">
                      {log.slice().reverse().map((entry) => (
                        <div className={`log-entry ${entry.tone}`} key={entry.id}>{entry.text}</div>
                      ))}
                    </div>
                  )}
                </>
              )}
              <button onClick={() => startMatch()} type="button">Queue Next Match</button>
              <button className="secondary-result-button" onClick={onExit} type="button">Back to Roguelike</button>
            </div>
          </div>
        )}
      </section>
    </main>
  )
}
