import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
  INITIAL_PARTY,
  RAID_PARTY,
  DEFAULT_GROUP_HEAL_TARGETS,
  partyDamageOutput,
  tankPressureTargets,
  type CombatLogEntry,
  type PartyMember,
  type Spell,
} from '../game'
import { completeRoguelike, recordPvpMatch, type DungeonReward } from '../profile'
import type { CharacterProfile, DungeonEncounter } from '../profile'
import type { GameMode } from '../gameRepository'
import { PartyMemberFrame } from './PartyFrames'
import { SpellBar } from './SpellBars'
import { barFillStyle } from './barStyles'
import { focusFirstControl, useGameAction, useInput } from '../input'
import { useDeadlineTimer, useRoundCountdown } from '../hooks/useCountdownTimer'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { useSidedFloatingCombatText } from '../hooks/useFloatingCombatText'
import { usePartyTargeting } from '../hooks/usePartyTargeting'
import {
  chooseRandom,
  createLogEntry,
  healMember,
  toCombatSpell,
} from '../combat/rules'
import { buildRoguelikeSegment } from '../combat/encounters'
import { chooseCpuHealActions, type CpuTurnBehavior } from '../combat/cpuAi'
import {
  buildOpponentSlotDebuffChoices,
  buildSelfSlotUpgradeChoices,
  summarizeChoiceStacks,
} from '../combat/roguelikeUpgrades'
import { createStackCounts, type StackCounts } from '../combat/stackCounts'
import {
  spellCooldownMultiplier,
  spellExtraTargets,
  spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers'
import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick } from '../combat/combatEngine'
import { buildPvpRoguelikeDualScreenState } from '../combat/dualScreenPayloads'
import { applyPvpSpellCast } from '../combat/pvpSpellCasting'
import {
  pruneExpiredCooldowns,
  reduceCooldown,
} from '../combat/spellCasting'
import {
  nextPvpRoguelikeStage,
  resolvePvpRoguelikeCombatOutcome,
  resolvePvpRoguelikeUpgradeCompletion,
  resolvePvpRoguelikeLiveSnapshot,
  type PvpRoguelikeStatus,
} from '../combat/pvpRoguelikeLifecycle'
import {
  applyPvpRoguelikeUpgradeChoice,
  preparePvpRoguelikeNextEncounter,
} from '../combat/pvpRoguelikeUpgrades'
import {
  createPvpRoguelikeLiveMatchStart,
  createPvpRoguelikeMatchStart,
  createPvpRoguelikeStarterSide,
} from '../combat/pvpRoguelikeMatchSetup'
import {
  createEmptyPvpRunSummary,
  mergePvpRunRewardSummary,
  type PvpRunRewardSummary,
} from '../combat/rewardSummaries'
import {
  appendCombatLog,
} from '../combat/combatPresentation'
import {
  buildSpellTargetPlan,
  type SpellEffectProfile,
} from '../combat/spellEffects'
import { BonusItemReward, PvpRunLootList, RewardXpSummary } from './RewardPanels'
import { ResultScreen } from './ResultScreen'
import {
  DualScreenTopCombat,
  useDualScreen,
  useDualScreenPublisher,
} from '../dualScreen'
import {
  loadPvpRoguelikeCheckpoint,
  loadPvpMatch,
  recordCpuPvpLeaderboard,
  recordPvpRoguelikeCheckpoint,
  submitPvpUpgradeChoice,
  type CpuDifficulty,
  type PvpMatchSnapshot,
  type PvpMatchSide,
  type PvpContentType,
  type PvpUpgradeChoicePayload,
} from '../pvpRoguelike'
import { startPvpQueueWithCpuFallback } from '../pvpQueueLifecycle'
import { usePvpLiveMatchSync } from '../pvpLiveLifecycle'

const TICK_MS = 700
const ROUND_START_SECONDS = 3
const UPGRADE_CHOICE_SECONDS = 15

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 DraftSlotKey = Exclude<SlotKey, '6'>
type AbilityLabelMode = 'ability' | 'slot'

type SelfBuffId =
  | 'revive-party-members'
  | `slot${DraftSlotKey}-extra-target`
  | `slot${DraftSlotKey}-cost-down`
  | `slot${DraftSlotKey}-cooldown-down`

type OpponentDebuffId =
  | `opp-slot${DraftSlotKey}-cost-up`
  | `opp-slot${DraftSlotKey}-cooldown-up`

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 LivePvpMatch = {
  id: string
  side: PvpMatchSide
  opponentSide: PvpMatchSide
  opponentName: string
  opponentClassName: string
}

type PvpOverlayNavEntry =
  | { kind: 'queueBack'; row: number; column: number }
  | { kind: 'pauseResume'; row: number; column: number }
  | { kind: 'pauseLeave'; row: number; column: number }
  | { kind: 'upgradeBuff'; index: number; row: number; column: number }
  | { kind: 'upgradeDebuff'; index: number; row: number; column: number }
  | { kind: 'upgradeContinue'; row: number; column: number; disabled?: boolean }

const REVIVE_PARTY_CHOICE: Choice<SelfBuffId> = {
  id: 'revive-party-members',
  name: 'Revive Party Members',
  description: 'Revive fallen party members before the next fight.',
}

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

const CPU_BEHAVIOR: Record<CpuDifficulty, CpuTurnBehavior> = {
  1: { actionEveryTicks: 3, mistakeChance: 0.28, directHealThreshold: 0.56, groupHealThreshold: 0.52, hotThreshold: 0.62, shieldThreshold: 0.5 },
  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 buildSelfBuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<SelfBuffId>> {
  return buildSelfSlotUpgradeChoices<SelfBuffId>({
    slots: ['1', '2', '3', '4', '5'] as DraftSlotKey[],
    spells,
    labelMode,
  })
}

function buildOpponentDebuffChoices(spells: Spell[], labelMode: AbilityLabelMode): Array<Choice<OpponentDebuffId>> {
  return buildOpponentSlotDebuffChoices<OpponentDebuffId>({
    slots: ['1', '2', '3', '4', '5'] as DraftSlotKey[],
    spells,
    labelMode,
  })
}

function cooldownMultiplier(spell: Spell, buffs: StackCounts<SelfBuffId>, debuffs: StackCounts<OpponentDebuffId>) {
  return spellCooldownMultiplier(
    spell,
    {
      stacks: buffs,
      id: (slot) => `slot${slot as SlotKey}-cooldown-down` as SelfBuffId,
    },
    {
      stacks: debuffs,
      id: (slot) => `opp-slot${slot as SlotKey}-cooldown-up` as OpponentDebuffId,
    },
  )
}

function spellResourceCost(spell: Spell, buffs: StackCounts<SelfBuffId>, debuffs: StackCounts<OpponentDebuffId>, freeCastReady: boolean) {
  void freeCastReady
  return modifiedSpellResourceCost({
    spell,
    costDown: {
      stacks: buffs,
      id: (slot) => `slot${slot as SlotKey}-cost-down` as SelfBuffId,
    },
    costUp: {
      stacks: debuffs,
      id: (slot) => `opp-slot${slot as SlotKey}-cost-up` as OpponentDebuffId,
    },
  })
}

function buildEncounterSegment(pool: DungeonEncounter[], stage: number, kind: PvpContentType): PvpEncounter[] {
  const mechanics = chooseRandom(BOSS_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
  const stageOneDamageScale = 0.8 + (kind === 'raid' ? 0.18 : 0.14)
  return buildRoguelikeSegment<BossMechanic, {
    bossMechanics: BossMechanic[]
    sourceEncounterId?: number
  }>({
    pool,
    stage,
    mechanics,
    fallbackBossFirst: true,
    trashCandidateCount: (trashCount) => Math.min(trashCount, 5 + stage * (kind === 'raid' ? 1 : 2)),
    bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
    healthScale: 0.75 + stage * (kind === 'raid' ? 0.28 : 0.22),
    damageScale: stageOneDamageScale,
    partyDamageScale: 0.89,
    idBase: 910000,
    bossDescription: (selectedMechanics) => `PvP boss with ${selectedMechanics.join(', ')}.`,
    extraFields: (encounter, isBoss, selectedMechanics) => ({
      sourceEncounterId: encounter.id,
      bossMechanics: isBoss ? selectedMechanics : [],
    }),
  }) as PvpEncounter[]
}

function scoreSelfBuff(buff: Choice<SelfBuffId>, spells: Spell[]) {
  if (buff.id === 'revive-party-members') return 10
  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) {
  void opponentBuffCount
  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 summarizeStacks<T extends string>(items: T[], catalog: Array<Choice<T>>) {
  return summarizeChoiceStacks(items, catalog)
}

export function PvPRoguelikeScreen({
  profile,
  gameMode,
  contentType,
  encounterPool,
  onExit,
  onMainMenu = onExit,
  onProfileUpdated,
}: {
  profile: CharacterProfile
  gameMode: GameMode
  contentType: PvpContentType
  encounterPool: DungeonEncounter[]
  onExit: () => void
  onMainMenu?: () => 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<PvpRoguelikeStatus>('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>(() => createPvpRoguelikeStarterSide<SelfBuffId, OpponentDebuffId>(partyTemplate, maxResource))
  const [cpuSide, setCpuSide] = useState<SideState>(() => createPvpRoguelikeStarterSide<SelfBuffId, OpponentDebuffId>(cpuPartyTemplate, maxResource))
  const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
  const selectedIdRef = useRef(partyTemplate[0].id)
  const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
  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<PvpRunRewardSummary>(() => createEmptyPvpRunSummary())
  const [rewardError, setRewardError] = useState('')
  const [showEndLog, setShowEndLog] = useState(false)
  const {
    playerFloatingTextsByMember,
    cpuFloatingTextsByMember,
    dualScreenFloatingTexts,
    addFloatingText,
    clearFloatingTexts,
  } = useSidedFloatingCombatText()
  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 [overlaySelectedIndex, setOverlaySelectedIndex] = useState(0)
  const [encountersCleared, setEncountersCleared] = useState(0)
  const [paused, setPaused] = useState(false)
  const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
  const nextLogId = useRef(2)
  const elapsedTicksRef = useRef(0)
  const recordedRunRef = useRef(false)
  const matchStatsRecordedRef = useRef(false)
  const rewardClaimedRef = useRef(false)
  const matchWinRewardClaimedRef = useRef(false)
  const bossRewardClaimedRef = useRef(new Set<number>())
  const cpuDefeatedRef = useRef(false)
  const playerClearedEncounterRef = useRef(-1)
  const queuedMatchRef = useRef(false)
  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 playerBuffCounts = useMemo(() => createStackCounts(playerSide.buffs), [playerSide.buffs])
  const playerDebuffCounts = useMemo(() => createStackCounts(playerSide.debuffs), [playerSide.debuffs])
  const playerSpellSlotCost = useCallback(
    (spell: Spell) => spellResourceCost(spell, playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady),
    [playerBuffCounts, playerDebuffCounts, playerSide.freeCastReady],
  )
  const playerSpellSlots = useSpellSlots({
    spells: starterSpells,
    cooldowns: playerSide.cooldowns,
    active: status === 'playing' && !paused,
    cost: playerSpellSlotCost,
  })
  const opponentBuffSummary = useMemo(
    () => cpuSide.buffs.length > 0 ? summarizeStacks(cpuSide.buffs, selfBuffChoicesCatalog) : 'none',
    [cpuSide.buffs, selfBuffChoicesCatalog],
  )
  const opponentDebuffSummary = useMemo(
    () => cpuSide.debuffs.length > 0 ? summarizeStacks(cpuSide.debuffs, opponentDebuffChoicesCatalog) : 'none',
    [cpuSide.debuffs, opponentDebuffChoicesCatalog],
  )
  const playerBuffSummary = useMemo(
    () => playerSide.buffs.length > 0 ? summarizeStacks(playerSide.buffs, selfBuffChoicesCatalog) : 'none',
    [playerSide.buffs, selfBuffChoicesCatalog],
  )
  const playerDebuffSummary = useMemo(
    () => playerSide.debuffs.length > 0 ? summarizeStacks(playerSide.debuffs, opponentDebuffChoicesCatalog) : 'none',
    [opponentDebuffChoicesCatalog, playerSide.debuffs],
  )
  const partyColumns = contentType === 'raid' ? 6 : 3
  const {
    bindings,
    controllerIconStyle,
    directPartyTargeting,
    lastDevice,
  } = useInput()
  const {
    enabled: dualScreenEnabled,
  } = useDualScreen()
  const activeBindings = bindings[lastDevice]

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

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

  const {
    timeLeft: roundCountdown,
    start: startRoundCountdown,
    clear: clearRoundCountdown,
  } = useRoundCountdown({
    seconds: ROUND_START_SECONDS,
    active: status === 'round-countdown',
    onComplete: () => {
      setStatus((current) => current === 'round-countdown' ? 'playing' : current)
    },
  })

  const {
    timeLeft: upgradeTimeLeft,
    start: startUpgradeTimer,
    reset: resetUpgradeTimer,
  } = useDeadlineTimer({
    initialSeconds: UPGRADE_CHOICE_SECONDS,
    active: status === 'upgrade-choice' && !liveUpgradePending,
    onExpire: () => {
      if (autoSubmittedUpgradeRef.current) return
      autoSubmittedUpgradeRef.current = true
      const autoBuff = selectedBuff ?? chooseRandom(playerBuffChoices, 1)[0]
      const autoDebuff = selectedDebuff ?? chooseRandom(playerDebuffChoices, 1)[0]
      if (autoBuff) setSelectedBuff(autoBuff)
      if (autoDebuff) setSelectedDebuff(autoDebuff)
      if (autoBuff && autoDebuff) {
        addLog('Upgrade timer expired. Random choices selected.', 'system')
        confirmUpgradeChoices(autoBuff, autoDebuff)
      }
    },
  })

  const addFloatingHeal = useCallback((side: 'player' | 'cpu', memberId: string, value: number) => {
    addFloatingText({ side, memberId, value })
  }, [addFloatingText])

  const beginRoundCountdown = useCallback((message?: string) => {
    clearRoundCountdown()
    startRoundCountdown()
    setStatus('round-countdown')
    if (message) addLog(message, 'system')
  }, [addLog, clearRoundCountdown, startRoundCountdown])

  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 awardEncounterReward = useCallback((encounterIndexValue: number) => {
    if (bossRewardClaimedRef.current.has(encounterIndexValue)) return
    bossRewardClaimedRef.current.add(encounterIndexValue)
    const rewardEncounter = encounters[encounterIndexValue]
    const isBossReward = Boolean(rewardEncounter?.isBoss)
    completeRoguelike(
      rewardDungeon.id,
      rewardDifficulty.id,
      0,
      0,
      Math.max(1, Math.round((elapsedTicksRef.current * TICK_MS) / 1000)),
      {
        bossesCleared: isBossReward ? 1 : 0,
        fightsCleared: 1,
        experienceMode: isBossReward ? 'pvp-boss-quarter-level' : 'pvp-fight-twelfth-level',
        lootSourceEncounterId: isBossReward ? rewardEncounter?.sourceEncounterId : undefined,
        roguelikeStage: isBossReward ? stage : undefined,
      },
    )
      .then((result) => {
        setReward(result)
        setRunSummary((current) => mergePvpRunRewardSummary(current, result, {
          bossKilled: isBossReward,
          sourceLabel: rewardEncounter?.enemyName,
        }))
        onProfileUpdated(result.profile)
        if (result.experienceGained > 0) {
          addLog(`+${result.experienceGained} XP awarded.`, 'loot')
        }
        if (result.bonusItem) {
          addLog(
            `${result.bonusItem.name} x${result.bonusItem.quantity} awarded.`,
            'loot',
          )
        }
        if (result.petAwarded) {
          addLog(
            `${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
            'loot',
          )
        }
      })
      .catch((reason: unknown) => {
        setRewardError(
          reason instanceof Error ? reason.message : 'Unable to award roguelike experience.',
        )
      })
  }, [addLog, encounters, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id, stage])

  const awardMatchWinReward = useCallback(() => {
    if (matchWinRewardClaimedRef.current) return
    matchWinRewardClaimedRef.current = true
    completeRoguelike(
      rewardDungeon.id,
      rewardDifficulty.id,
      0,
      0,
      Math.max(1, Math.round((elapsedTicksRef.current * TICK_MS) / 1000)),
      {
        bossesCleared: 0,
        fightsCleared: 0,
        experienceMode: 'pvp-match-win-half-level',
      },
    )
      .then((result) => {
        setReward(result)
        setRunSummary((current) => mergePvpRunRewardSummary(current, result, {
          bossKilled: false,
          sourceLabel: 'Match Win',
        }))
        onProfileUpdated(result.profile)
        if (result.experienceGained > 0) {
          addLog(`Match win bonus: +${result.experienceGained} XP.`, 'loot')
        }
      })
      .catch((reason: unknown) => {
        setRewardError(
          reason instanceof Error ? reason.message : 'Unable to award match win experience.',
        )
      })
  }, [addLog, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id])

  const finishRoguelikeRun = useCallback((result: 'won' | 'lost') => {
    if (rewardClaimedRef.current) return
    rewardClaimedRef.current = true
    if (result === 'won') awardMatchWinReward()
  }, [awardMatchWinReward])

  useEffect(() => {
    const frame = window.requestAnimationFrame(() => {
      setPlayerBuffChoices((current) => current
        .map((choice) => choice.id === REVIVE_PARTY_CHOICE.id
          ? REVIVE_PARTY_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
        ? (current.id === REVIVE_PARTY_CHOICE.id ? REVIVE_PARTY_CHOICE : selfBuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current)
        : null)
      setSelectedDebuff((current) => current
        ? opponentDebuffChoicesCatalog.find((candidate) => candidate.id === current.id) ?? current
        : null)
    })
    return () => window.cancelAnimationFrame(frame)
  }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog])

  const startLiveMatch = useCallback((
    match: PvpMatchSnapshot<SideState>,
    side: PvpMatchSide,
    message?: string,
  ) => {
    const setup = createPvpRoguelikeLiveMatchStart<SelfBuffId, OpponentDebuffId, PvpEncounter>({
      match,
      side,
      encounterPool: encounterPoolRef.current,
      buildSegment: (pool, nextStage) => buildEncounterSegment([...pool], nextStage, contentType),
      partyTemplate,
      opponentPartyTemplate: cpuPartyTemplate,
      maxResource,
      upgradeChoiceSeconds: UPGRADE_CHOICE_SECONDS,
      message,
    })
    playerRef.current = setup.playerSide
    cpuRef.current = setup.opponentSide
    liveMatchRef.current = setup.liveMatch
    nextLogId.current = 2
    playerClearedEncounterRef.current = -1
    queuedMatchRef.current = true
    bossRewardClaimedRef.current = new Set()
    elapsedTicksRef.current = setup.defaults.elapsedTicks
    setEncounters(setup.firstSegment)
    setEncounterIndex(setup.defaults.encounterIndex)
    setCheckpointStage(setup.startStage)
    setStartStage(setup.startStage)
    setStage(setup.startStage)
    setPlayerSide(setup.playerSide)
    setCpuSide(setup.opponentSide)
    setSelectedTargetId(partyTemplate[0].id)
    setPlayerBuffChoices([])
    setPlayerDebuffChoices([])
    setSelectedBuff(null)
    setSelectedDebuff(null)
    resetUpgradeTimer(setup.defaults.upgradeTimeLeft)
    autoSubmittedUpgradeRef.current = false
    setEncountersCleared(setup.defaults.encountersCleared)
    setPaused(setup.defaults.paused)
    setTargetGroup(setup.defaults.targetGroup)
    setReward(null)
    setRunSummary(createEmptyPvpRunSummary())
    setRewardError(setup.defaults.rewardError)
    setShowEndLog(setup.defaults.showEndLog)
    clearFloatingTexts()
    setCpuDifficulty(null)
    setLiveMatch(setup.liveMatch)
    setLiveUpgradePending(setup.defaults.liveUpgradePending)
    pendingLiveUpgradeRef.current = null
    loggedOpponentDoneRef.current = false
    recordedRunRef.current = false
    matchStatsRecordedRef.current = false
    rewardClaimedRef.current = false
    matchWinRewardClaimedRef.current = false
    cpuDefeatedRef.current = false
    setQueueMessage(setup.logText)
    setLog([{ id: 1, text: setup.logText, tone: 'system' }])
    beginRoundCountdown()
  }, [beginRoundCountdown, clearFloatingTexts, contentType, cpuPartyTemplate, maxResource, partyTemplate, resetUpgradeTimer, setSelectedTargetId])

  const startMatch = useCallback((nextStartStage?: number) => {
    clearRoundCountdown()
    const matchStartStage = nextStartStage ?? loadPvpRoguelikeCheckpoint(profile.character.id, contentType)
    const setup = createPvpRoguelikeMatchStart<SelfBuffId, OpponentDebuffId, PvpEncounter>({
      startStage: matchStartStage,
      encounterPool: encounterPoolRef.current,
      buildSegment: (pool, nextStage) => buildEncounterSegment([...pool], nextStage, contentType),
      partyTemplate,
      opponentPartyTemplate: cpuPartyTemplate,
      maxResource,
      upgradeChoiceSeconds: UPGRADE_CHOICE_SECONDS,
    })
    playerRef.current = setup.playerSide
    cpuRef.current = setup.opponentSide
    nextLogId.current = 2
    playerClearedEncounterRef.current = -1
    queuedMatchRef.current = true
    bossRewardClaimedRef.current = new Set()
    elapsedTicksRef.current = setup.defaults.elapsedTicks
    setEncounters(setup.firstSegment)
    setEncounterIndex(setup.defaults.encounterIndex)
    setCheckpointStage(setup.startStage)
    setStartStage(setup.startStage)
    setStage(setup.startStage)
    setStatus('queueing')
    setOverlaySelectedIndex(0)
    setPlayerSide(setup.playerSide)
    setCpuSide(setup.opponentSide)
    setSelectedTargetId(partyTemplate[0].id)
    setPlayerBuffChoices([])
    setPlayerDebuffChoices([])
    setSelectedBuff(null)
    setSelectedDebuff(null)
    resetUpgradeTimer(setup.defaults.upgradeTimeLeft)
    autoSubmittedUpgradeRef.current = false
    setEncountersCleared(setup.defaults.encountersCleared)
    setPaused(setup.defaults.paused)
    setTargetGroup(setup.defaults.targetGroup)
    setReward(null)
    setRunSummary(createEmptyPvpRunSummary())
    setRewardError(setup.defaults.rewardError)
    setShowEndLog(setup.defaults.showEndLog)
    clearFloatingTexts()
    setCpuDifficulty(null)
    setLiveMatch(null)
    liveMatchRef.current = null
    setLiveUpgradePending(setup.defaults.liveUpgradePending)
    pendingLiveUpgradeRef.current = null
    loggedOpponentDoneRef.current = false
    recordedRunRef.current = false
    matchStatsRecordedRef.current = false
    rewardClaimedRef.current = false
    matchWinRewardClaimedRef.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' }])
      beginRoundCountdown(`Stage ${matchStartStage} begins against CPU ${randomCpu}.`)
    }
    return startPvpQueueWithCpuFallback<SideState>({
      contentType,
      startStage: matchStartStage,
      gameMode,
      liveMatchActive: () => Boolean(liveMatchRef.current),
      onSearching: (message) => {
        setQueueMessage(message)
        setLog([{ id: 1, text: message, tone: 'system' }])
      },
      onCpuMatch: beginCpuMatch,
      onLiveMatch: (match, side, message) => startLiveMatch(match, side, message),
      messages: {
        offline: (difficulty) => `Offline mode. CPU ${difficulty} enters at stage ${matchStartStage}.`,
        searching: `Searching queue for 5s. Stage ${matchStartStage} start ready.`,
        notFound: (difficulty) => `No queued player found after 5s. CPU ${difficulty} steps in.`,
        unavailable: (difficulty) => `PvP server unavailable. CPU ${difficulty} steps in.`,
        liveFound: (match, side) => {
          const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
          return `${match.players[opponentSide].characterName} found. Stage ${match.startStage} begins.`
        },
      },
    })
  }, [beginRoundCountdown, clearFloatingTexts, clearRoundCountdown, contentType, cpuPartyTemplate, gameMode, maxResource, partyTemplate, profile.character.id, resetUpgradeTimer, setSelectedTargetId, startLiveMatch])

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

  const { rematchRequested, rematchMessage, handleRematch } = usePvpLiveMatchSync<SideState, LivePvpMatch>({
    liveMatch,
    syncEnabled: status !== 'queueing',
    startLiveMatch,
    getPayload: () => ({
      state: playerRef.current,
      status: status === 'upgrade-choice' ? 'upgrade-choice' : status === 'won' || status === 'lost' ? status : 'playing',
      stage,
      encounterIndex,
      encountersCleared,
      enemyHealth: playerRef.current.enemyHealth,
      alive: playerRef.current.party.some((member) => member.health > 0),
      elapsedTicks: elapsedTicksRef.current,
    }),
    onSnapshot: (snapshot, currentLiveMatch) => {
      const opponentState = snapshot.states[currentLiveMatch.opponentSide]
      if (opponentState) {
        cpuRef.current = opponentState
        setCpuSide(opponentState)
      }
      const opponentStatus = snapshot.statuses[currentLiveMatch.opponentSide]
      const opponentAlive = snapshot.progress[currentLiveMatch.opponentSide]?.alive
      const outcome = resolvePvpRoguelikeLiveSnapshot({
        currentStatus: status,
        opponentStatus,
        opponentAlive,
        alreadyLoggedOpponentDone: loggedOpponentDoneRef.current,
        opponentName: currentLiveMatch.opponentName,
      })
      if (outcome.type === 'won') {
        loggedOpponentDoneRef.current = true
        cpuDefeatedRef.current = true
        finishRoguelikeRun('won')
        setStatus(outcome.status)
        addLog(outcome.log.text, outcome.log.tone)
      }
      if (outcome.type === 'lost') {
        finishRoguelikeRun('lost')
        setStatus(outcome.status)
        addLog(outcome.log.text, outcome.log.tone)
      }
    },
  })

  const applySpell = useCallback((
    current: SideState,
    setCurrent: React.Dispatch<React.SetStateAction<SideState>>,
    sideName: 'player' | 'cpu',
    buffs: SelfBuffId[],
    debuffs: OpponentDebuffId[],
    spell: Spell,
    targetId: string,
  ) => {
    const buffCounts = createStackCounts(buffs)
    const debuffCounts = createStackCounts(debuffs)
    const effectiveCost = spellResourceCost(spell, buffCounts, debuffCounts, current.freeCastReady)
    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 extraTargets = spellExtraTargets(spell, {
      stacks: buffCounts,
      id: (slot) => `slot${slot as SlotKey}-extra-target` as SelfBuffId,
    })
    const {
      directTargets,
      hotTargets,
      shieldTargets,
      groupTargets,
    } = buildSpellTargetPlan({
      party: current.party,
      spell,
      targetId,
      extraTargets,
      directTarget: true,
      hotTarget: spell.kind === 'hot',
      shieldTarget: spell.kind === 'shield',
      groupTargetCount: DEFAULT_GROUP_HEAL_TARGETS + extraTargets,
      extraTargetMode: {
        hot: 'hot',
        shield: 'shield',
      },
    })
    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 spellEffectProfile: SpellEffectProfile = {
      modeName: 'pvp-roguelike',
      heal: healMember,
      healingMultiplier,
      power: {
        direct: (source) => source.power,
        cleanse: (source) => source.power,
        groupHeal: (source) => source.power,
        groupAbsorb: (source) => source.power,
        shield: (sourcePower, strength = 1) => Math.round(sourcePower * strength),
      },
      hot: {
        mode: 'ticks',
        defaultTicks: 5,
        groupTicks: 5,
        radianceTicks: 3,
        merge: 'replace',
        groupMerge: 'max',
      },
      effects: {
        renewSpell: renewEffect,
        shieldSpell: shieldEffect,
        groupAbsorbOnly: () => false,
        groupHotOnly: () => false,
        groupAppliesShield: () => hasSpellEffect('radiance_applies_shield'),
        groupAppliesHot: () => hasSpellEffect('radiance_applies_renew') && Boolean(renewEffect),
        shieldAppliesHot: () => false,
        hotSpellForDirect: (source) => source,
      },
      ratios: {
        groupShield: 0.3,
        directShield: 0.5,
      },
      damageReductionTicks: 12,
      floatingHeals: {
        group: true,
        direct: true,
        cleanse: true,
      },
      bounceHeals: false,
    }
    const targetPlan = {
        directTargets,
        hotTargets,
        shieldTargets,
        damageReductionTargets: new Set<string>(),
        groupTargets,
      }
    let nextCooldowns = current.cooldowns
    if (spell.kind === 'direct' && hasSpellEffect('mend_reduces_radiance_cooldown') && radianceEffect) {
      nextCooldowns = reduceCooldown(nextCooldowns, radianceEffect.id, 2)
    }
    return applyPvpSpellCast({
      current,
      spell,
      targetId,
      resourceCost: effectiveCost,
      targetPlan,
      profile: spellEffectProfile,
      setCurrent,
      emitFloatingHeal: (memberId, value) => addFloatingHeal(sideName, memberId, value),
      cooldowns: nextCooldowns,
      cooldownMultiplier: cooldownMultiplier(spell, buffCounts, debuffCounts),
      freeCast: {
        enabled: false,
        wasReady: false,
      },
    })
  }, [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 getTargetParty = useCallback(() => playerRef.current.party, [])
  const {
    selectRelativeTarget,
    selectDirectionalTarget,
    selectDirectTarget,
  } = usePartyTargeting({
    getParty: getTargetParty,
    selectedIdRef,
    setSelectedTargetId,
    columns: partyColumns,
    livingOnly: true,
    directTargetGroup: contentType === 'raid' ? targetGroup : 0,
  })

  const cpuTakeTurn = useCallback(() => {
    if (!cpuDifficulty || status !== 'playing' || cpuDone || !cpuAlive) return
    const elapsedTicks = elapsedTicksRef.current
    if (elapsedTicks % cpuBehavior.actionEveryTicks !== 0) return
    if (Math.random() < cpuBehavior.mistakeChance) return
    const side = cpuRef.current
    const actions = chooseCpuHealActions({ party: side.party, spells: starterSpells, behavior: cpuBehavior })
    for (const action of actions) {
      const cast = 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 (cast) return
    }
  }, [applySpell, cpuAlive, cpuBehavior, cpuDifficulty, cpuDone, starterSpells, status])

  const advanceSide = useCallback((side: SideState, sideName: 'player' | 'cpu', encounterValue: PvpEncounter): SideState => {
    if (side.enemyHealth <= 0) return side
    const party = side.party
    const living = 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 elapsedTicks = elapsedTicksRef.current
    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 playerEffectsActive = sideName === 'player'
    const hasShieldedHealingBonus = playerEffectsActive && activeSpellEffects.has('shielded_healing_bonus')
    const shieldedDamageMultiplier = playerEffectsActive && activeSpellEffects.has('shielded_damage_reduction') ? 0.8 : undefined
    const tankPressure = tankPressureTargets(party)
    const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
    const nextParty = 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 healingMultiplier = member.shield > 0 && hasShieldedHealingBonus ? 1.2 : 1
      const result = advanceMemberTick({
        member,
        party,
        damage,
        hotHealing: 6,
        hotTicks: 'ticks',
        healingMultiplier,
        shieldedDamageMultiplier,
        applyDebuff: appliesDebuff && member.id === primaryTarget.id
          ? { label: 'Searing Mark', ticks: 8 }
          : undefined,
        applyPoisonStacks: appliesPoison && member.id === primaryTarget.id,
        poisonDamage: (stacks) => 3 + stacks * 3,
        applyMaxHealthPenaltyTicks: appliesMaxHealthCut && member.id === primaryTarget.id ? 14 : undefined,
        applyHealingReductionTicks: appliesHealingReduction && member.id === primaryTarget.id ? 14 : undefined,
      })
      if (result.floatingHeal > 0) addFloatingHeal(sideName, member.id, result.floatingHeal)
      return result.member
    })
    return {
      ...side,
      party: nextParty,
      resource: regenerateResource(side.resource, 2.4, maxResource),
      cooldowns: pruneExpiredCooldowns(side.cooldowns),
      enemyHealth: Math.max(0, side.enemyHealth - partyDamageOutput(nextParty, encounterValue.partyDamage)),
    }
  }, [activeSpellEffects, addFloatingHeal, maxResource])

  const beginUpgradePhase = useCallback(() => {
    autoSubmittedUpgradeRef.current = false
    startUpgradeTimer()
    setPlayerBuffChoices(chooseRandom(selfBuffChoicesCatalog, 3))
    setPlayerDebuffChoices(chooseRandom(opponentDebuffChoicesCatalog, 3))
    setSelectedBuff(null)
    setSelectedDebuff(null)
    setOverlaySelectedIndex(0)
    setStatus('upgrade-choice')
  }, [opponentDebuffChoicesCatalog, selfBuffChoicesCatalog, startUpgradeTimer])

  useEffect(() => {
    if (status !== 'playing' || paused || !encounter) return
    const timer = window.setInterval(() => {
      elapsedTicksRef.current += 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)
        awardEncounterReward(encounterIndex)
        if (encounter.isBoss) {
          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)
      const outcome = resolvePvpRoguelikeCombatOutcome({
        playerAlive: nextPlayerAlive,
        opponentAlive: nextCpuAlive,
        playerCleared: nextPlayer.enemyHealth <= 0,
        encounterIsBoss: encounter.isBoss,
        encounterName: encounter.enemyName,
        opponentDefeated: cpuDefeatedRef.current,
        liveMatchActive: Boolean(liveMatch),
        opponentLabel,
      })
      if (outcome.type === 'lost') {
        finishRoguelikeRun('lost')
        setStatus(outcome.status)
        addLog(outcome.log.text, outcome.log.tone)
        return
      }
      if (outcome.type === 'won') {
        if (outcome.markOpponentDefeated) {
          cpuDefeatedRef.current = true
        }
        finishRoguelikeRun('won')
        setStatus(outcome.status)
        addLog(outcome.log.text, outcome.log.tone)
        return
      }
      if (outcome.type === 'upgrade-choice') {
        addLog(outcome.log.text, outcome.log.tone)
        beginUpgradePhase()
      }
    }, TICK_MS / speedMultiplier)
    return () => window.clearInterval(timer)
  }, [addLog, advanceSide, awardEncounterReward, beginUpgradePhase, checkpointStage, contentType, cpuTakeTurn, encounter, encounterIndex, encountersCleared, finishRoguelikeRun, liveMatch, opponentLabel, 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 !== 'won' && status !== 'lost') return
    if (matchStatsRecordedRef.current) return
    matchStatsRecordedRef.current = true
    recordPvpMatch(status === 'won')
      .then(onProfileUpdated)
      .catch((reason: unknown) => {
        addLog(
          reason instanceof Error ? reason.message : 'Unable to record PvP match.',
          'danger',
        )
      })
  }, [addLog, onProfileUpdated, 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) => {
        const incomingDebuffId = opponentDebuffChoicesCatalog.some((choice) => choice.id === opponentChoice.debuffId)
          ? opponentChoice.debuffId as OpponentDebuffId
          : undefined
        let nextPlayer = applyPvpRoguelikeUpgradeChoice({
          side: playerRef.current,
          buffId: submittedBuff.id,
          incomingDebuffId,
          reviveBuffId: REVIVE_PARTY_CHOICE.id,
        })

        const clearedBoss = encounter.isBoss
        const completion = resolvePvpRoguelikeUpgradeCompletion({
          clearedBoss,
          opponentDefeated: cpuDefeatedRef.current,
          opponentLabel: liveMatch.opponentName,
        })
        if (completion.type === 'won') {
          finishRoguelikeRun('won')
          setStatus(completion.status)
          setLiveUpgradePending(false)
          addLog(completion.log.text, completion.log.tone)
          return
        }
        const nextStage = clearedBoss ? stage + 1 : stage
        const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
        const progression = nextPvpRoguelikeStage({
          clearedBoss,
          stage,
          encounterIndex,
          encounters,
          nextSegment,
        })
        if (progression.type === 'complete') {
          finishRoguelikeRun('won')
          setStatus(progression.status)
          setLiveUpgradePending(false)
          addLog(progression.log.text, progression.log.tone)
          return
        }
        nextPlayer = preparePvpRoguelikeNextEncounter({
          side: nextPlayer,
          maxResource,
          enemyMaxHealth: progression.nextEncounter.maxHealth,
        })
        if (clearedBoss) {
          setStage(progression.nextStage)
          setEncounters((current) => [...current, ...nextSegment])
        }
        setEncounterIndex(progression.nextEncounterIndex)
        setPlayerSide(nextPlayer)
        playerRef.current = nextPlayer
        elapsedTicksRef.current = 0
        setLiveUpgradePending(false)
        pendingLiveUpgradeRef.current = null
        beginRoundCountdown()
        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 = applyPvpRoguelikeUpgradeChoice({
      side: playerRef.current,
      buffId: chosenBuff.id,
      incomingDebuffId: cpuDebuff.id,
      reviveBuffId: REVIVE_PARTY_CHOICE.id,
    })
    let nextCpu = applyPvpRoguelikeUpgradeChoice({
      side: cpuRef.current,
      buffId: cpuBuff.id,
      incomingDebuffId: chosenDebuff.id,
      reviveBuffId: REVIVE_PARTY_CHOICE.id,
    })

    const clearedBoss = encounter.isBoss
    const completion = resolvePvpRoguelikeUpgradeCompletion({
      clearedBoss,
      opponentDefeated: cpuDefeatedRef.current,
      opponentLabel: 'CPU',
    })
    if (completion.type === 'won') {
      finishRoguelikeRun('won')
      setStatus(completion.status)
      addLog(completion.log.text, completion.log.tone)
      return
    }
    const nextStage = clearedBoss ? stage + 1 : stage
    const nextSegment = clearedBoss ? buildEncounterSegment(encounterPool, nextStage, contentType) : []
    const progression = nextPvpRoguelikeStage({
      clearedBoss,
      stage,
      encounterIndex,
      encounters,
      nextSegment,
    })
    if (progression.type === 'complete') {
      finishRoguelikeRun('won')
      setStatus(progression.status)
      addLog(progression.log.text, progression.log.tone)
      return
    }

    nextPlayer = preparePvpRoguelikeNextEncounter({
      side: nextPlayer,
      maxResource,
      enemyMaxHealth: progression.nextEncounter.maxHealth,
    })
    nextCpu = preparePvpRoguelikeNextEncounter({
      side: nextCpu,
      maxResource,
      enemyMaxHealth: progression.nextEncounter.maxHealth,
    })

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

  function pvpOverlayEntries(): PvpOverlayNavEntry[] {
    if (status === 'queueing') return [{ kind: 'queueBack', row: 0, column: 0 }]
    if (paused) {
      return [
        { kind: 'pauseResume', row: 0, column: 0 },
        { kind: 'pauseLeave', row: 1, column: 0 },
      ]
    }
    if (status !== 'upgrade-choice') return []
    const entries: PvpOverlayNavEntry[] = [
      ...playerBuffChoices.map((_, index) => ({
        kind: 'upgradeBuff' as const,
        index,
        row: index,
        column: 0,
      })),
      ...playerDebuffChoices.map((_, index) => ({
        kind: 'upgradeDebuff' as const,
        index,
        row: index,
        column: 1,
      })),
    ]
    entries.push({
      kind: 'upgradeContinue',
      row: Math.max(playerBuffChoices.length, playerDebuffChoices.length),
      column: 1,
      disabled: !selectedBuff || !selectedDebuff || liveUpgradePending,
    })
    return entries
  }

  function pvpOverlayEntryDisabled(entry: PvpOverlayNavEntry) {
    return entry.kind === 'upgradeContinue' && Boolean(entry.disabled)
  }

  function activePvpOverlayEntry(entries = pvpOverlayEntries()) {
    const bounded = Math.min(overlaySelectedIndex, entries.length - 1)
    const active = entries[bounded]
    if (active && !pvpOverlayEntryDisabled(active)) return active
    return entries.find((entry) => !pvpOverlayEntryDisabled(entry))
  }

  function pvpOverlayEntrySelected(kind: PvpOverlayNavEntry['kind'], index?: number) {
    const active = activePvpOverlayEntry()
    if (!active || active.kind !== kind) return false
    if ('index' in active || index !== undefined) return 'index' in active && active.index === index
    return true
  }

  function setPvpOverlayCursor(kind: PvpOverlayNavEntry['kind'], index?: number) {
    const entries = pvpOverlayEntries()
    const nextIndex = entries.findIndex((entry) => {
      if (entry.kind !== kind) return false
      if ('index' in entry || index !== undefined) return 'index' in entry && entry.index === index
      return true
    })
    if (nextIndex >= 0) setOverlaySelectedIndex(nextIndex)
  }

  function movePvpOverlaySelection(action: string) {
    const entries = pvpOverlayEntries()
    const enabledEntries = entries
      .map((entry, index) => ({ entry, index }))
      .filter(({ entry }) => !pvpOverlayEntryDisabled(entry))
    if (enabledEntries.length === 0) return
    setOverlaySelectedIndex((current) => {
      const candidate = entries[Math.min(current, entries.length - 1)]
      const active = candidate && !pvpOverlayEntryDisabled(candidate)
        ? candidate
        : enabledEntries[0]?.entry
      if (!active) return current
      const candidates = enabledEntries.filter(({ entry }) => {
        if (entry === active) return false
        if (action === 'navigateLeft') return entry.row === active.row && entry.column < active.column
        if (action === 'navigateRight') return entry.row === active.row && entry.column > active.column
        if (action === 'navigateUp') return entry.row < active.row
        return entry.row > active.row
      })
      if (candidates.length === 0) return entries.findIndex((entry) => entry === active)
      candidates.sort((a, b) => {
        const aPrimary = Math.abs(a.entry.row - active.row) + Math.abs(a.entry.column - active.column)
        const bPrimary = Math.abs(b.entry.row - active.row) + Math.abs(b.entry.column - active.column)
        return aPrimary - bPrimary || a.index - b.index
      })
      return candidates[0]?.index ?? current
    })
  }

  function openPvpOverlayEntry(entry: PvpOverlayNavEntry | undefined) {
    if (!entry || pvpOverlayEntryDisabled(entry)) return
    if (entry.kind === 'queueBack') onExit()
    else if (entry.kind === 'pauseResume') setPaused(false)
    else if (entry.kind === 'pauseLeave') onMainMenu()
    else if (entry.kind === 'upgradeBuff') setSelectedBuff(playerBuffChoices[entry.index] ?? null)
    else if (entry.kind === 'upgradeDebuff') setSelectedDebuff(playerDebuffChoices[entry.index] ?? null)
    else if (entry.kind === 'upgradeContinue') confirmUpgradeChoices()
  }

  useGameAction((action) => {
    if (status === 'queueing') {
      if (action === 'back' || action === 'pause') onExit()
      if (status === 'queueing' && action === 'confirm') openPvpOverlayEntry(activePvpOverlayEntry())
      return
    }
    if (paused) {
      if (action === 'back' || action === 'pause') {
        setPaused(false)
        return
      }
      if (action === 'confirm') {
        openPvpOverlayEntry(activePvpOverlayEntry())
        return
      }
      if (action.startsWith('navigate')) movePvpOverlaySelection(action)
      return
    }
    if (status === 'round-countdown') {
      if (action === 'back' || action === 'pause') {
        setOverlaySelectedIndex(0)
        setPaused(true)
      }
      return
    }
    if (status === 'upgrade-choice') {
      if (action === 'confirm') {
        openPvpOverlayEntry(activePvpOverlayEntry())
        return
      }
      if (action.startsWith('navigate')) movePvpOverlaySelection(action)
      return
    }
    if (action === 'toggleSpeed') {
      if (status === 'playing') setSpeedMultiplier((value) => (value === 1 ? 2 : 1))
      return
    }
    if (action === 'pause' || action === 'back') {
      if (status === 'playing') {
        setOverlaySelectedIndex(0)
        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(() => buildPvpRoguelikeDualScreenState({
    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,
    opponentEnemyHealth: cpuSide.enemyHealth,
    opponentResource: cpuSide.resource,
    opponentMaxResource: maxResource,
    opponentResourceName: gameClass.resourceName,
    opponentBuffSummary,
    opponentDebuffSummary,
    floatingTexts: dualScreenFloatingTexts,
    partySize: playerSide.party.length,
    selectedId,
    status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status,
    resource: playerSide.resource,
    maxResource,
    resourceName: gameClass.resourceName,
    playerIsAlive: playerAlive,
    spells: playerSpellSlots,
    bindings: activeBindings,
    controllerIconStyle,
    directPartyTargeting,
    paused,
    targetGroup,
    speedMultiplier,
  }), [
    activeBindings,
    controllerIconStyle,
    cpuDifficulty,
    cpuSide.enemyHealth,
    cpuSide.party,
    cpuSide.resource,
    directPartyTargeting,
    encounter.description,
    encounter.enemyName,
    encounter.isBoss,
    encounter.maxHealth,
    encounterIndex,
    encounters.length,
    dualScreenFloatingTexts,
    gameClass.resourceName,
    liveMatch?.opponentClassName,
    maxResource,
    opponentBuffSummary,
    opponentDebuffSummary,
    opponentLabel,
    paused,
    playerAlive,
    playerSide.enemyHealth,
    playerSide.party,
    playerSide.resource,
    selectedId,
    speedMultiplier,
    stage,
    playerSpellSlots,
    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 pvp-match-queue-panel" data-game-nav-active="true">
            <div className="placeholder-runes">P V P</div>
            <p>{queueMessage}</p>
            <button
              className={`secondary-result-button ${pvpOverlayEntrySelected('queueBack') ? 'game-selected' : ''}`}
              data-controller-nav="skip"
              onClick={onExit}
              onPointerDown={() => setPvpOverlayCursor('queueBack')}
              type="button"
            >
              Back
            </button>
          </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>
                  <small>{encounter.enemyName} | Stage {stage}{encounter.isBoss ? ' Boss' : ''}</small>
                </div>
                <div className="pvp-side-bars">
                  <div className="pvp-clear-wrap">
                    <span>Your clear {Math.max(0, Math.floor(playerSide.enemyHealth))} / {encounter.maxHealth}</span>
                    <div className="bar enemy-health boss-bar">
                      <span style={barFillStyle((playerSide.enemyHealth / encounter.maxHealth) * 100)} />
                    </div>
                  </div>
                  <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={barFillStyle((playerSide.resource / maxResource) * 100)} /></div>
                  </div>
                </div>
              </div>
              <div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
                {playerSide.party.map((member) => (
                  <PartyMemberFrame
                    deadClassName="down"
                    effectMode="timed-basic"
                    floatingTexts={playerFloatingTextsByMember.get(member.id) ?? []}
                    key={`player-${member.id}`}
                    member={member}
                    onSelect={setSelectedTargetId}
                    selected={selectedId === member.id}
                    showHealthText
                  />
                ))}
              </div>
              <p className="roguelike-upgrade-list">
                Buffs: {playerBuffSummary} | Debuffs: {playerDebuffSummary}
              </p>
            </section>

            <section className="combat-panel pvp-side">
              <div className="encounter-header">
                <div>
                  <p className="eyebrow">Opponent</p>
                  <h2>{opponentLabel}</h2>
                  <small>{liveMatch ? liveMatch.opponentClassName : `CPU ${cpuDifficulty}`} | Encounters cleared: {encountersCleared}</small>
                </div>
                <div className="pvp-side-bars">
                  <div className="pvp-clear-wrap">
                    <span>{liveMatch ? `${liveMatch.opponentName} clear` : 'CPU clear'} {Math.max(0, Math.floor(cpuSide.enemyHealth))} / {encounter.maxHealth}</span>
                    <div className="bar enemy-health boss-bar">
                      <span style={barFillStyle((cpuSide.enemyHealth / encounter.maxHealth) * 100)} />
                    </div>
                  </div>
                  <div className="pvp-resource-wrap">
                    <span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {maxResource}</span>
                    <div className="bar mana-bar"><span style={barFillStyle((cpuSide.resource / maxResource) * 100)} /></div>
                  </div>
                </div>
              </div>
              <div className={`party-grid pvp-party-grid ${contentType === 'raid' ? 'raid' : ''}`}>
                {cpuSide.party.map((member) => (
                  <PartyMemberFrame
                    as="div"
                    deadClassName="down"
                    effectMode="timed-basic"
                    floatingTexts={cpuFloatingTextsByMember.get(member.id) ?? []}
                    key={`cpu-${member.id}`}
                    member={member}
                    showHealthText
                  />
                ))}
              </div>
              <p className="roguelike-upgrade-list">
                Buffs: {opponentBuffSummary} | Debuffs: {opponentDebuffSummary}
              </p>
            </section>

            <SpellBar
              bindings={activeBindings}
              canCast={status === 'playing' && !playerDone && playerAlive}
              className="pvp-bottom-spell-bar"
              focusable={false}
              iconStyle={controllerIconStyle}
              onCast={castPlayerSpell}
              resource={playerSide.resource}
              resourceName={gameClass.resourceName}
              spells={playerSpellSlots}
            />
          </div>
        )}

        {status === 'round-countdown' && (
          <div className="pvp-round-countdown">
            <div>
              <p className="eyebrow">Round Starts</p>
              <h2>{Math.max(1, Math.ceil(roundCountdown))}</h2>
            </div>
          </div>
        )}

        {status === 'upgrade-choice' && (
          <div className="result-screen" data-game-nav-active="true">
            <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>{playerBuffChoices.length === 1 && playerBuffChoices[0]?.id === REVIVE_PARTY_CHOICE.id ? 'Recovery' : 'Self Buff'}</strong>
                  <div className="upgrade-choice-grid">
                    {playerBuffChoices.map((choice) => (
                      <button
                        className={`${selectedBuff?.id === choice.id ? 'selected-upgrade' : ''} ${pvpOverlayEntrySelected('upgradeBuff', playerBuffChoices.indexOf(choice)) ? 'game-selected' : ''}`}
                        data-controller-nav="skip"
                        key={choice.id}
                        onClick={() => setSelectedBuff(choice)}
                        onPointerDown={() => setPvpOverlayCursor('upgradeBuff', playerBuffChoices.indexOf(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' : ''} ${pvpOverlayEntrySelected('upgradeDebuff', playerDebuffChoices.indexOf(choice)) ? 'game-selected' : ''}`}
                        data-controller-nav="skip"
                        key={choice.id}
                        onClick={() => setSelectedDebuff(choice)}
                        onPointerDown={() => setPvpOverlayCursor('upgradeDebuff', playerDebuffChoices.indexOf(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 ${pvpOverlayEntrySelected('upgradeContinue') ? 'game-selected' : ''}`}
                data-controller-nav="skip"
                disabled={!selectedBuff || !selectedDebuff || liveUpgradePending}
                onClick={() => confirmUpgradeChoices()}
                onPointerDown={() => setPvpOverlayCursor('upgradeContinue')}
                type="button"
              >
                {liveUpgradePending ? 'Waiting' : 'Continue'}
              </button>
            </div>
          </div>
        )}

        {paused && (
          <div className="pause-screen" data-game-nav-active="true">
            <div>
              <p className="eyebrow">Paused</p>
              <h2>{contentType === 'raid' ? 'Raid Clash' : 'Dungeon Clash'}</h2>
              <button
                className={pvpOverlayEntrySelected('pauseResume') ? 'game-selected' : ''}
                data-controller-nav="skip"
                onClick={() => setPaused(false)}
                onPointerDown={() => setPvpOverlayCursor('pauseResume')}
                type="button"
              >
                Continue
              </button>
              <button
                className={`secondary-result-button ${pvpOverlayEntrySelected('pauseLeave') ? 'game-selected' : ''}`}
                data-controller-nav="skip"
                onClick={onMainMenu}
                onPointerDown={() => setPvpOverlayCursor('pauseLeave')}
                type="button"
              >
                Main Menu
              </button>
            </div>
          </div>
        )}

        {(status === 'won' || status === 'lost') && (
          <ResultScreen
            actions={[
              { label: 'Queue Next Match', onClick: () => startMatch() },
              { label: 'Back to Roguelike', onClick: onExit, className: 'secondary-result-button' },
            ]}
            eyebrow={status === 'won' ? 'Victory' : 'Defeat'}
            log={log}
            onToggleLog={() => setShowEndLog((value) => !value)}
            rematch={{
              visible: Boolean(liveMatch),
              requested: rematchRequested,
              message: rematchMessage,
              onRematch: handleRematch,
            }}
            showLog={showEndLog}
            title={status === 'won' ? `${opponentLabel} Falls` : `${opponentLabel} Wins`}
          >
              <p>{finalEncountersCleared} encounters cleared.</p>
              <div className="reward-summary">
                <p>Run totals</p>
                <p>{runSummary.bossesKilled} bosses killed.</p>
                <RewardXpSummary reward={runSummary} />
                {runSummary.bossesKilled > 0 && !reward && !rewardError && <p>Final boss rewards still recording...</p>}
                {rewardError && <p className="reward-error">{rewardError}</p>}
                <PvpRunLootList loot={runSummary.loot} emptyLabel="No boss coins collected this run." />
                {reward && runSummary.bossesKilled === 0 && (
                  <>
                    <RewardXpSummary reward={reward} />
                    <BonusItemReward item={reward.bonusItem} compact />
                  </>
                )}
              </div>
          </ResultScreen>
        )}
      </section>
    </main>
  )
}
