import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import {
  DEFAULT_GROUP_HEAL_TARGETS,
  INITIAL_PARTY,
  groupHealTargets,
  tankPressureTargets,
  type CombatLogEntry,
  type PartyMember,
  type Spell,
} from '../game'
import { completeRoguelike, type DungeonReward } from '../profile'
import type { Ability, CharacterProfile } from '../profile'
import type { GameMode } from '../gameRepository'
import { ControllerBindingLabel } from './ControllerIcons'
import { focusFirstControl, useGameAction, useInput, type InputAction } from '../input'
import { useDualScreen, useDualScreenPublisher, type DualScreenCombatState } from '../dualScreen'
import {
  cancelPvpQueue,
  checkPvpQueue,
  joinPvpQueue,
  publishPvpMatchState,
  randomCpuDifficulty,
  requestPvpRematch,
  submitPvpUpgradeChoice,
  type CpuDifficulty,
  type PvpMatchSide,
  type PvpMatchSnapshot,
  type PvpRematchResponse,
} from '../pvpRoguelike'

const TICK_MS = 700
const ROUND_START_SECONDS = 3
const SHOP_SECONDS = 60
const WIN_ROUNDS = 3
const MAX_RESOURCE = 100
const RESOURCE_REGEN_PER_TICK = 0.8

type SlotKey = '1' | '2' | '3' | '4' | '5'
type StadiumBuffId =
  | `slot${SlotKey}-extra-target`
  | `slot${SlotKey}-cost-down`
  | `slot${SlotKey}-cooldown-down`
  | 'slot1-applies-renew'
  | 'slot1-applies-shield'
  | 'slot2-applies-shield'
  | 'slot2-double-duration'
  | 'slot3-applies-shield'
  | 'slot3-applies-renew'
  | 'slot4-applies-renew'
  | 'slot5-applies-renew'
  | 'slot5-applies-shield'
  | 'fifth-cast-free'
  | 'group-heal-boost'
  | 'shield-boost'

type StadiumBuff = {
  id: StadiumBuffId
  name: string
  description: string
  category: SlotKey | 'misc'
  cost: 1 | 2
}

type StadiumSideState = {
  party: PartyMember[]
  resource: number
  cooldowns: Record<string, number>
  buffs: StadiumBuffId[]
  castsTowardFree: number
  freeCastReady: boolean
  survivalSeconds: number
  dampeningPercent: number
  roundIndex: number
  roundWins: number
  roundStatus: 'playing' | 'shop' | 'won' | 'lost'
  lastRoundOutcome?: 'win' | 'loss' | 'tie'
  shopReady: boolean
}

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

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

type RewardSummary = {
  experienceGained: number
  previousLevel: number | null
  newLevel: number | null
  levelsGained: number
  talentPointsGained: number
  unlockedAbilities: DungeonReward['unlockedAbilities']
}

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.54, groupHealThreshold: 0.5, hotThreshold: 0.6, shieldThreshold: 0.48 },
  2: { actionEveryTicks: 3, mistakeChance: 0.24, directHealThreshold: 0.6, groupHealThreshold: 0.56, hotThreshold: 0.66, shieldThreshold: 0.54 },
  3: { actionEveryTicks: 3, mistakeChance: 0.16, directHealThreshold: 0.66, groupHealThreshold: 0.62, hotThreshold: 0.72, shieldThreshold: 0.6 },
  4: { actionEveryTicks: 2, mistakeChance: 0.08, directHealThreshold: 0.72, groupHealThreshold: 0.68, hotThreshold: 0.78, shieldThreshold: 0.66 },
  5: { actionEveryTicks: 2, mistakeChance: 0.03, directHealThreshold: 0.78, groupHealThreshold: 0.74, hotThreshold: 0.84, shieldThreshold: 0.72 },
}

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

function formatTime(seconds: number) {
  const total = Math.max(0, Math.floor(seconds))
  const minutes = Math.floor(total / 60)
  const remaining = total % 60
  return `${minutes}:${String(remaining).padStart(2, '0')}`
}

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

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

function buffStacks(items: StadiumBuffId[], id: StadiumBuffId) {
  return items.filter((item) => item === id).length
}

function slotLabel(slot: SlotKey, spells: Spell[]) {
  const spell = spells.find((candidate) => candidate.key === slot)
  return spell ? `${spell.name} (Slot ${slot})` : `Slot ${slot}`
}

function slotSpellName(slot: SlotKey, spells: Spell[], fallback: string) {
  return spells.find((candidate) => candidate.key === slot)?.name ?? fallback
}

function buildStadiumBuffs(spells: Spell[]): StadiumBuff[] {
  const directName = slotSpellName('1', spells, 'Mend')
  const sustainName = slotSpellName('2', spells, 'Renew')
  const groupName = slotSpellName('3', spells, 'Radiance')
  const shieldName = slotSpellName('4', spells, 'Sun Ward')
  const cleanseName = slotSpellName('5', spells, 'Purify')
  const slotBuffs = (['1', '2', '3', '4', '5'] as SlotKey[]).flatMap((slot) => {
    const label = slotLabel(slot, spells)
    const baseBuffs: StadiumBuff[] = [
      {
        id: `slot${slot}-extra-target` as StadiumBuffId,
        name: '+1 target',
        description: `${label} affects 1 additional ally when possible.`,
        category: slot,
        cost: 2,
      },
      {
        id: `slot${slot}-cost-down` as StadiumBuffId,
        name: '-25% cost',
        description: `${label} costs 25% less mana.`,
        category: slot,
        cost: 1,
      },
      {
        id: `slot${slot}-cooldown-down` as StadiumBuffId,
        name: '-25% cooldown',
        description: `${label} recharges 25% faster.`,
        category: slot,
        cost: 1,
      },
    ]
    const specialBuffs: Partial<Record<SlotKey, StadiumBuff[]>> = {
      1: [
        {
          id: 'slot1-applies-renew',
          name: `Applies ${sustainName}`,
          description: `${directName} also applies ${sustainName} to the target.`,
          category: slot,
          cost: 2,
        },
        {
          id: 'slot1-applies-shield',
          name: `Applies ${shieldName}`,
          description: `${directName} also applies a ${shieldName} barrier to the target.`,
          category: slot,
          cost: 2,
        },
      ],
      2: [
        {
          id: 'slot2-applies-shield',
          name: `Applies ${shieldName}`,
          description: `${sustainName} also applies a ${shieldName} barrier to the target.`,
          category: slot,
          cost: 2,
        },
        {
          id: 'slot2-double-duration',
          name: 'Double Duration',
          description: `${sustainName} lasts twice as long or gains extra charges.`,
          category: slot,
          cost: 2,
        },
      ],
      3: [
        {
          id: 'slot3-applies-shield',
          name: `Applies 50% ${shieldName}`,
          description: `${groupName} applies a ${shieldName} barrier at 50% strength to affected targets.`,
          category: slot,
          cost: 2,
        },
        {
          id: 'slot3-applies-renew',
          name: `Applies ${sustainName}`,
          description: `${groupName} applies ${sustainName} to affected targets.`,
          category: slot,
          cost: 2,
        },
      ],
      4: [
        {
          id: 'slot4-applies-renew',
          name: `Applies ${sustainName}`,
          description: `${shieldName} also applies ${sustainName} to the target.`,
          category: slot,
          cost: 2,
        },
      ],
      5: [
        {
          id: 'slot5-applies-renew',
          name: `Applies ${sustainName}`,
          description: `${cleanseName} also applies ${sustainName} to the target.`,
          category: slot,
          cost: 2,
        },
        {
          id: 'slot5-applies-shield',
          name: `Applies ${shieldName}`,
          description: `${cleanseName} also applies a ${shieldName} barrier to the target.`,
          category: slot,
          cost: 2,
        },
      ],
    }
    return [...baseBuffs, ...(specialBuffs[slot] ?? [])]
  })
  return [
    ...slotBuffs,
    {
      id: 'fifth-cast-free',
      name: 'Stored Momentum',
      description: 'After 5 spell casts, your next cast is free.',
      category: 'misc',
      cost: 1,
    },
    {
      id: 'group-heal-boost',
      name: 'Wide Radiance',
      description: 'Party healing is 25% stronger.',
      category: 'misc',
      cost: 1,
    },
    {
      id: 'shield-boost',
      name: 'Dense Shields',
      description: 'Shield absorbs are 25% stronger.',
      category: 'misc',
      cost: 1,
    },
  ]
}

function summarizeStacks(items: StadiumBuffId[], catalog: StadiumBuff[]) {
  const counts = new Map<StadiumBuffId, number>()
  items.forEach((item) => counts.set(item, (counts.get(item) ?? 0) + 1))
  const summary = 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(', ')
  return summary || 'none'
}

function cooldownMultiplier(spell: Spell, buffs: StadiumBuffId[]) {
  return 0.75 ** buffStacks(buffs, `slot${spell.key as SlotKey}-cooldown-down` as StadiumBuffId)
}

function spellResourceCost(spell: Spell, buffs: StadiumBuffId[], freeCastReady: boolean) {
  if (freeCastReady && buffStacks(buffs, 'fifth-cast-free') > 0) return 0
  return Math.ceil(spell.cost * (0.75 ** buffStacks(buffs, `slot${spell.key as SlotKey}-cost-down` as StadiumBuffId)))
}

function toCombatSpell(ability: Ability, key: string): Spell {
  const kinds: Record<string, Spell['kind']> = {
    direct_heal: 'direct',
    direct_hot: 'direct',
    heal_over_time: 'hot',
    bounce_heal: 'bounce_heal',
    party_heal: 'group',
    party_hot: 'group',
    party_absorb: 'group',
    absorb: 'shield',
    damage_reduction: 'damage_reduction',
    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',
    effectType: ability.spellType,
  }
}

function resetParty(partyTemplate: PartyMember[]) {
  return partyTemplate.map((member) => ({
    ...member,
    health: member.maxHealth,
    shield: 0,
    hotTicks: 0,
    hotEffects: undefined,
    debuff: undefined,
    debuffTicks: undefined,
    poisonStacks: undefined,
    maxHealthPenaltyTicks: undefined,
    healingReductionTicks: undefined,
    damageReductionTicks: undefined,
    bounceHeals: undefined,
  }))
}

function starterSide(partyTemplate: PartyMember[], roundIndex: number, buffs: StadiumBuffId[] = [], roundWins = 0): StadiumSideState {
  return {
    party: resetParty(partyTemplate),
    resource: MAX_RESOURCE,
    cooldowns: {},
    buffs,
    castsTowardFree: 0,
    freeCastReady: false,
    survivalSeconds: 0,
    dampeningPercent: 0,
    roundIndex,
    roundWins,
    roundStatus: 'playing',
    shopReady: false,
  }
}

function createEmptyRewardSummary(): RewardSummary {
  return {
    experienceGained: 0,
    previousLevel: null,
    newLevel: null,
    levelsGained: 0,
    talentPointsGained: 0,
    unlockedAbilities: [],
  }
}

export function PvpStadiumScreen({
  profile,
  gameMode,
  onExit,
  onProfileUpdated,
}: {
  profile: CharacterProfile
  gameMode: GameMode
  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, 5)
    .map((spell, index) => toCombatSpell(spell, String(index + 1))), [gameClass.spells])
  const buffCatalog = useMemo(() => buildStadiumBuffs(starterSpells), [starterSpells])
  const partyTemplate = useMemo(
    () => INITIAL_PARTY.map((member) => ({
      ...member,
      name: member.id === 'mira' ? profile.character.name : member.name,
    })),
    [profile.character.name],
  )
  const cpuPartyTemplate = useMemo(
    () => INITIAL_PARTY.map((member) => ({
      ...member,
      name: member.id === 'mira' ? 'CPU Healer' : member.name,
    })),
    [],
  )
  const rewardDungeon = profile.dungeons.find((candidate) => candidate.contentType === 'dungeon') ?? profile.dungeons[0]
  const rewardDifficulty = rewardDungeon.difficulties[0]
  const [status, setStatus] = useState<'queueing' | 'round-countdown' | 'playing' | 'shop' | 'won' | 'lost'>('queueing')
  const [playerSide, setPlayerSide] = useState<StadiumSideState>(() => starterSide(partyTemplate, 1))
  const [cpuSide, setCpuSide] = useState<StadiumSideState>(() => starterSide(cpuPartyTemplate, 1))
  const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
  const [roundIndex, setRoundIndex] = useState(1)
  const [roundWins, setRoundWins] = useState({ player: 0, opponent: 0 })
  const [shopPoints, setShopPoints] = useState(0)
  const [shopReady, setShopReady] = useState(false)
  const [shopTimeLeft, setShopTimeLeft] = useState(SHOP_SECONDS)
  const [shopCategory, setShopCategory] = useState<SlotKey | 'misc'>('1')
  const [roundCountdown, setRoundCountdown] = useState(ROUND_START_SECONDS)
  const [elapsedTicks, setElapsedTicks] = useState(0)
  const [cpuDifficulty, setCpuDifficulty] = useState<CpuDifficulty | null>(null)
  const [liveMatch, setLiveMatch] = useState<LivePvpMatch | null>(null)
  const [queueMessage, setQueueMessage] = useState('Searching Stadium queue...')
  const [rematchRequested, setRematchRequested] = useState(false)
  const [rematchMessage, setRematchMessage] = useState('')
  const [paused, setPaused] = useState(false)
  const [log, setLog] = useState<CombatLogEntry[]>([{ id: 1, text: 'Queueing Stadium opponent...', tone: 'system' }])
  const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
  const [rewardSummary, setRewardSummary] = useState<RewardSummary>(() => createEmptyRewardSummary())
  const [rewardError, setRewardError] = useState('')
  const [showEndLog, setShowEndLog] = useState(false)
  const selectedIdRef = useRef(partyTemplate[0].id)
  const playerRef = useRef(playerSide)
  const cpuRef = useRef(cpuSide)
  const liveMatchRef = useRef<LivePvpMatch | null>(null)
  const nextLogId = useRef(2)
  const nextFloatingTextId = useRef(1)
  const roundCountdownTimerRef = useRef<number | null>(null)
  const shopEndsAtRef = useRef(0)
  const submittedShopRef = useRef(false)
  const awardedXpRef = useRef(new Set<string>())
  const queuedMatchRef = useRef(false)
  const roundResolvedRef = useRef(false)
  const loggedOpponentRoundRef = useRef('')
  const {
    bindings,
    controllerIconStyle,
    directPartyTargeting,
    lastDevice,
  } = useInput()
  const { enabled: dualScreenEnabled } = useDualScreen()
  const opponentLabel = liveMatch ? liveMatch.opponentName : `CPU ${cpuDifficulty ?? 1}`
  const playerAlive = playerSide.party.some((member) => member.health > 0)
  const partyColumns = 3

  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, 70))
  }, [])

  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)
  }, [])

  const clearRoundCountdown = useCallback(() => {
    if (roundCountdownTimerRef.current === null) return
    window.clearInterval(roundCountdownTimerRef.current)
    roundCountdownTimerRef.current = null
  }, [])

  const beginRoundCountdown = useCallback(() => {
    clearRoundCountdown()
    roundResolvedRef.current = false
    setRoundCountdown(ROUND_START_SECONDS)
    setStatus('round-countdown')
    const startedAt = Date.now()
    roundCountdownTimerRef.current = window.setInterval(() => {
      const remaining = Math.max(0, ROUND_START_SECONDS - (Date.now() - startedAt) / 1000)
      setRoundCountdown(remaining)
      if (remaining > 0) return
      clearRoundCountdown()
      setStatus((current) => current === 'round-countdown' ? 'playing' : current)
    }, 100)
  }, [clearRoundCountdown])

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

  const awardXp = useCallback((key: string, mode: 'pvp-stadium-round-win-quarter-level' | 'pvp-stadium-round-loss-tenth-level' | 'pvp-stadium-match-half-level') => {
    if (awardedXpRef.current.has(key)) return
    awardedXpRef.current.add(key)
    completeRoguelike(rewardDungeon.id, rewardDifficulty.id, 0, 0, Math.max(1, Math.floor(playerRef.current.survivalSeconds || 1)), {
      bossesCleared: 0,
      fightsCleared: 1,
      experienceMode: mode,
    })
      .then((result) => {
        setRewardSummary((current) => {
          const unlockedById = new Map(current.unlockedAbilities.map((ability) => [ability.id, ability]))
          result.unlockedAbilities.forEach((ability) => unlockedById.set(ability.id, ability))
          return {
            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()),
          }
        })
        onProfileUpdated(result.profile)
        if (result.experienceGained > 0) addLog(`+${result.experienceGained} XP awarded.`, 'loot')
      })
      .catch((reason: unknown) => {
        setRewardError(reason instanceof Error ? reason.message : 'Unable to award Stadium XP.')
      })
  }, [addLog, onProfileUpdated, rewardDifficulty.id, rewardDungeon.id])

  const startLiveMatch = useCallback((match: PvpMatchSnapshot<StadiumSideState>, side: PvpMatchSide, message?: string) => {
    const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
    const opponent = match.players[opponentSide]
    const basePlayer = starterSide(partyTemplate, 1)
    const baseOpponent = starterSide(
      cpuPartyTemplate.map((member) => ({
        ...member,
        name: member.id === 'mira' ? opponent.characterName : member.name,
      })),
      1,
    )
    const nextLiveMatch = {
      id: match.id,
      side,
      opponentSide,
      opponentName: opponent.characterName,
      opponentClassName: opponent.className,
    }
    playerRef.current = basePlayer
    cpuRef.current = baseOpponent
    liveMatchRef.current = nextLiveMatch
    queuedMatchRef.current = true
    nextLogId.current = 2
    awardedXpRef.current = new Set()
    roundResolvedRef.current = false
    setPlayerSide(basePlayer)
    setCpuSide(baseOpponent)
    setRoundIndex(1)
    setRoundWins({ player: 0, opponent: 0 })
    setSelectedTargetId(partyTemplate[0].id)
    setElapsedTicks(0)
    setShopPoints(0)
    setShopReady(false)
    setCpuDifficulty(null)
    setLiveMatch(nextLiveMatch)
    setPaused(false)
    setRewardSummary(createEmptyRewardSummary())
    setRewardError('')
    setShowEndLog(false)
    setFloatingTexts([])
    setRematchRequested(false)
    setRematchMessage('')
    loggedOpponentRoundRef.current = ''
    const text = message ?? `${opponent.characterName} found. Stadium begins.`
    setQueueMessage(text)
    setLog([{ id: 1, text, tone: 'system' }])
    beginRoundCountdown()
  }, [beginRoundCountdown, cpuPartyTemplate, partyTemplate, setSelectedTargetId])

  const startMatch = useCallback(() => {
    clearRoundCountdown()
    const basePlayer = starterSide(partyTemplate, 1)
    const baseCpu = starterSide(cpuPartyTemplate, 1)
    playerRef.current = basePlayer
    cpuRef.current = baseCpu
    liveMatchRef.current = null
    queuedMatchRef.current = true
    nextLogId.current = 2
    awardedXpRef.current = new Set()
    roundResolvedRef.current = false
    setPlayerSide(basePlayer)
    setCpuSide(baseCpu)
    setRoundIndex(1)
    setRoundWins({ player: 0, opponent: 0 })
    setSelectedTargetId(partyTemplate[0].id)
    setElapsedTicks(0)
    setStatus('queueing')
    setShopPoints(0)
    setShopReady(false)
    setCpuDifficulty(null)
    setLiveMatch(null)
    setPaused(false)
    setRewardSummary(createEmptyRewardSummary())
    setRewardError('')
    setShowEndLog(false)
    setFloatingTexts([])
    setRematchRequested(false)
    setRematchMessage('')
    loggedOpponentRoundRef.current = ''
    const beginCpuMatch = (randomCpu: CpuDifficulty, message: string) => {
      setCpuDifficulty(randomCpu)
      setQueueMessage(message)
      setLog([{ id: 1, text: message, tone: 'system' }])
      beginRoundCountdown()
    }
    if (gameMode === 'offline') {
      const randomCpu = randomCpuDifficulty()
      const timer = window.setTimeout(() => {
        beginCpuMatch(randomCpu, `Offline mode. CPU ${randomCpu} enters Stadium.`)
      }, 500)
      return () => window.clearTimeout(timer)
    }
    let cancelled = false
    let ticketId = ''
    let pollTimer: number | undefined
    setQueueMessage('Searching Stadium queue for 5s.')
    setLog([{ id: 1, text: 'Searching Stadium queue for 5s.', tone: 'system' }])
    const beginLiveMatch = (match: PvpMatchSnapshot<StadiumSideState>, side: PvpMatchSide) => {
      if (cancelled) return
      const opponentSide: PvpMatchSide = side === 'a' ? 'b' : 'a'
      const opponent = match.players[opponentSide]
      startLiveMatch(match, side, `${opponent.characterName} found. Stadium begins.`)
    }
    const fallbackTimer = window.setTimeout(() => {
      if (cancelled || liveMatchRef.current) return
      cancelled = true
      if (ticketId) cancelPvpQueue(ticketId).catch(() => undefined)
      const randomCpu = randomCpuDifficulty()
      beginCpuMatch(randomCpu, `No Stadium player found after 5s. CPU ${randomCpu} steps in.`)
    }, 5000)
    const pollQueue = () => {
      if (!ticketId || cancelled) return
      checkPvpQueue<StadiumSideState>(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<StadiumSideState>('stadium', 1)
      .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)
    }
  }, [beginRoundCountdown, clearRoundCountdown, cpuPartyTemplate, gameMode, partyTemplate, setSelectedTargetId, startLiveMatch])

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

  const applySpell = useCallback((
    current: StadiumSideState,
    setCurrent: Dispatch<SetStateAction<StadiumSideState>>,
    sideName: 'player' | 'cpu',
    spell: Spell,
    targetId: string,
  ) => {
    const effectiveCost = spellResourceCost(spell, current.buffs, 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 dampenMultiplier = Math.max(0, 1 - current.dampeningPercent / 100)
    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 directTargets = new Set<string>(spell.kind === 'direct' || spell.kind === 'cleanse' ? [targetId] : [])
    const hotTargets = new Set<string>(spell.kind === 'hot' || spell.kind === 'bounce_heal' ? [targetId] : [])
    const shieldTargets = new Set<string>(spell.kind === 'shield' ? [targetId] : [])
    const damageReductionTargets = new Set<string>(spell.kind === 'damage_reduction' ? [targetId] : [])
    const extraTargets = buffStacks(current.buffs, `slot${spell.key as SlotKey}-extra-target` as StadiumBuffId)
    const groupTargets = new Set(
      spell.kind === 'group'
        ? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
        : [],
    )
    const renewDuration = buffStacks(current.buffs, 'slot2-double-duration') > 0 && spell.key === '2' ? 10 : 5
    const shieldEffect = starterSpells.find((candidate) => candidate.kind === 'shield')
    const shieldPower = (sourcePower: number, strength = 1) => Math.round(
      sourcePower
      * strength
      * (1.25 ** buffStacks(current.buffs, 'shield-boost'))
      * dampenMultiplier,
    )
    if (spell.effectType === 'direct_hot') hotTargets.add(targetId)
    if (spell.key === '1' && buffStacks(current.buffs, 'slot1-applies-renew') > 0) hotTargets.add(targetId)
    if (spell.key === '1' && buffStacks(current.buffs, 'slot1-applies-shield') > 0) shieldTargets.add(targetId)
    if (spell.key === '2' && buffStacks(current.buffs, 'slot2-applies-shield') > 0) shieldTargets.add(targetId)
    if (spell.key === '4' && buffStacks(current.buffs, 'slot4-applies-renew') > 0) hotTargets.add(targetId)
    if (spell.key === '5' && buffStacks(current.buffs, 'slot5-applies-renew') > 0) hotTargets.add(targetId)
    if (spell.key === '5' && buffStacks(current.buffs, 'slot5-applies-shield') > 0) shieldTargets.add(targetId)
    for (let index = 0; index < extraTargets; index += 1) {
      if (spell.kind === 'group') break
      if (spell.kind === 'hot' || spell.kind === 'bounce_heal') {
        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
      }
      if (spell.kind === 'damage_reduction') {
        const extra = extraTarget([...damageReductionTargets])
        if (extra) damageReductionTargets.add(extra.id)
        continue
      }
      const extra = extraTarget([...directTargets])
      if (extra) directTargets.add(extra.id)
    }
    if (spell.effectType === 'direct_hot') directTargets.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 isGroupAbsorb = spell.effectType === 'party_absorb'
        const isGroupHot = spell.effectType === 'party_hot'
        const boost = isGroupAbsorb ? buffStacks(current.buffs, 'shield-boost') : buffStacks(current.buffs, 'group-heal-boost')
        const power = Math.round(spell.power * (1.25 ** boost) * dampenMultiplier)
        const nextHealth = isGroupAbsorb || isGroupHot ? member.health : clamp(member.health + power, 0, effectiveMaxHealth(member))
        if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health)
        const appliesShield = isGroupAbsorb || buffStacks(current.buffs, 'slot3-applies-shield') > 0
        const appliesHot = isGroupHot || buffStacks(current.buffs, 'slot3-applies-renew') > 0
        return {
          ...member,
          health: nextHealth,
          shield: appliesShield
            ? Math.max(member.shield, isGroupAbsorb ? power : shieldPower(shieldEffect?.power ?? spell.power, 0.5))
            : member.shield,
          hotTicks: appliesHot ? Math.max(member.hotTicks, 5) : member.hotTicks,
        }
      }
      if (
        !directTargets.has(member.id)
        && !hotTargets.has(member.id)
        && !shieldTargets.has(member.id)
        && !damageReductionTargets.has(member.id)
      ) return member
      if (spell.kind === 'shield') {
        return {
          ...member,
          shield: Math.max(member.shield, shieldPower(spell.power)),
          hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks,
        }
      }
      if (spell.kind === 'damage_reduction') {
        return {
          ...member,
          damageReductionTicks: Math.max(member.damageReductionTicks ?? 0, 12),
          hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks,
        }
      }
      if (spell.kind === 'cleanse') {
        const power = Math.round(spell.power * dampenMultiplier)
        const nextHealth = clamp(member.health + power, 0, effectiveMaxHealth(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,
          shield: shieldTargets.has(member.id)
            ? Math.max(member.shield, shieldPower(shieldEffect?.power ?? spell.power))
            : member.shield,
          hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, 5) : member.hotTicks,
        }
      }
      const power = directTargets.has(member.id) ? Math.round(spell.power * dampenMultiplier) : 0
      const nextHealth = clamp(member.health + power, 0, effectiveMaxHealth(member))
      if (nextHealth > member.health) addFloatingHeal(sideName, member.id, nextHealth - member.health)
      return {
        ...member,
        health: nextHealth,
        shield: shieldTargets.has(member.id)
          ? Math.max(member.shield, shieldPower(shieldEffect?.power ?? spell.power))
          : member.shield,
        hotTicks: hotTargets.has(member.id) ? Math.max(member.hotTicks, renewDuration) : member.hotTicks,
      }
    })
    const freeBuff = buffStacks(current.buffs, 'fifth-cast-free') > 0
    const wasFree = effectiveCost === 0 && current.freeCastReady
    const nextCasts = freeBuff ? (wasFree ? 0 : current.castsTowardFree + 1) : current.castsTowardFree
    const nextState = {
      ...current,
      party: nextParty,
      resource: current.resource - effectiveCost,
      cooldowns: {
        ...current.cooldowns,
        [spell.id]: spell.cooldown * cooldownMultiplier(spell, current.buffs),
      },
      castsTowardFree: freeBuff && nextCasts >= 5 ? 0 : nextCasts,
      freeCastReady: freeBuff && nextCasts >= 5,
    }
    setCurrent(nextState)
    return true
  }, [addFloatingHeal, starterSpells])

  const castPlayerSpell = useCallback((spell: Spell) => {
    if (status !== 'playing' || !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', spell, targetId)
    if (succeeded) addLog(`${spell.name} cast on ${playerRef.current.party.find((member) => member.id === targetId)?.name ?? 'target'}.`, 'heal')
  }, [addLog, applySpell, playerAlive, 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 member = playerRef.current.party[slot]
    if (member?.health > 0) setSelectedTargetId(member.id)
  }, [setSelectedTargetId])

  const cpuTakeTurn = useCallback(() => {
    if (!cpuDifficulty || status !== 'playing') return
    const behavior = CPU_BEHAVIOR[cpuDifficulty]
    if (elapsedTicks % behavior.actionEveryTicks !== 0 || Math.random() < behavior.mistakeChance) return
    const side = cpuRef.current
    const living = side.party.filter((member) => member.health > 0)
    if (living.length === 0) return
    const lowest = [...living].sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
    const averageHealth = living.reduce((total, member) => total + member.health / effectiveMaxHealth(member), 0) / Math.max(1, living.length)
    const tank = living.find((member) => member.role === 'Tank')
    const cleanseTarget = living.find((member) => member.debuff || (member.poisonStacks ?? 0) > 0)
    const renewTarget = living.find((member) => member.hotTicks <= 1 && member.health / effectiveMaxHealth(member) < behavior.hotThreshold)
    const shieldTarget = living.find((member) => member.role === 'Tank' && member.shield <= 5 && member.health / effectiveMaxHealth(member) < behavior.shieldThreshold)
    const spellBySlot = (slot: SlotKey) => starterSpells.find((candidate) => candidate.key === slot)
    const ordered: Array<{ spell: Spell | undefined; targetId: string | null }> = [
      { spell: cleanseTarget ? spellBySlot('5') : undefined, targetId: cleanseTarget?.id ?? null },
      { spell: averageHealth < behavior.groupHealThreshold ? spellBySlot('3') : undefined, targetId: lowest?.id ?? null },
      { spell: shieldTarget ? spellBySlot('4') : undefined, targetId: shieldTarget?.id ?? null },
      { spell: lowest.health / effectiveMaxHealth(lowest) < behavior.directHealThreshold ? spellBySlot('1') : undefined, targetId: lowest.id },
      { spell: renewTarget ? spellBySlot('2') : undefined, targetId: renewTarget?.id ?? null },
      { spell: tank ? spellBySlot('1') : 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', action.spell, action.targetId)
      if (succeeded) return
    }
  }, [applySpell, cpuDifficulty, elapsedTicks, starterSpells, status])

  const advanceBoss = useCallback((side: StadiumSideState) => {
    if (side.roundStatus !== 'playing') return side
    const nextSurvival = side.survivalSeconds + TICK_MS / 1000
    const dampeningPercent = Math.floor(nextSurvival / 5)
    const dampenMultiplier = Math.max(0, 1 - dampeningPercent / 100)
    const living = side.party.filter((member) => member.health > 0)
    if (living.length === 0) return side
    const spikeTarget = living[Math.floor(Math.random() * living.length)]
    const tankIds = new Set(tankPressureTargets(side.party).targets.map((member) => member.id))
    const pulse = elapsedTicks > 0 && elapsedTicks % 5 === 0
    const spike = elapsedTicks > 0 && elapsedTicks % 8 === 0
    const nextParty = side.party.map((member) => {
      if (member.health <= 0) return member
      let damage = tankIds.has(member.id) ? 8 : 0
      if (pulse) damage += 9
      if (spike && member.id === spikeTarget.id) damage += 22
      const mitigatedDamage = member.damageReductionTicks && member.damageReductionTicks > 0
        ? Math.ceil(damage * 0.5)
        : damage
      const hotHealing = member.hotTicks > 0 ? Math.round(6 * dampenMultiplier) : 0
      const absorbed = Math.min(member.shield, mitigatedDamage)
      const nextHealth = clamp(member.health - mitigatedDamage + absorbed + hotHealing, 0, effectiveMaxHealth(member))
      return {
        ...member,
        health: nextHealth,
        shield: Math.max(0, member.shield - mitigatedDamage),
        hotTicks: Math.max(0, member.hotTicks - 1),
        damageReductionTicks: member.damageReductionTicks ? Math.max(0, member.damageReductionTicks - 1) : undefined,
        debuff: spike && member.id === spikeTarget.id ? 'Marked' : member.debuff,
        debuffTicks: spike && member.id === spikeTarget.id ? 4 : member.debuffTicks ? Math.max(0, member.debuffTicks - 1) : undefined,
      }
    })
    return {
      ...side,
      party: nextParty,
      resource: clamp(side.resource + RESOURCE_REGEN_PER_TICK, 0, MAX_RESOURCE),
      cooldowns: Object.fromEntries(
        Object.entries(side.cooldowns).map(([id, seconds]) => [id, Math.max(0, seconds - TICK_MS / 1000)]),
      ),
      survivalSeconds: nextSurvival,
      dampeningPercent,
    }
  }, [elapsedTicks])

  const beginShop = useCallback((outcome: 'win' | 'loss' | 'tie', nextWins: { player: number; opponent: number }) => {
    const points = outcome === 'loss' ? 4 : 3
    shopEndsAtRef.current = Date.now() + SHOP_SECONDS * 1000
    submittedShopRef.current = false
    setShopPoints(points)
    setShopReady(false)
    setShopTimeLeft(SHOP_SECONDS)
    setRoundWins(nextWins)
    setStatus('shop')
    setPlayerSide((current) => {
      const next = { ...current, roundStatus: 'shop' as const, lastRoundOutcome: outcome, shopReady: false, roundWins: nextWins.player }
      playerRef.current = next
      return next
    })
    if (!liveMatchRef.current) {
      let cpuPoints = outcome === 'win' ? 4 : 3
      const purchases: StadiumBuffId[] = []
      while (cpuPoints > 0) {
        const affordable = buffCatalog.filter((buff) => buff.cost <= cpuPoints)
        if (affordable.length === 0) break
        const selected = affordable[Math.floor(Math.random() * affordable.length)]
        purchases.push(selected.id)
        cpuPoints -= selected.cost
      }
      setCpuSide((current) => {
        const next = { ...current, buffs: [...current.buffs, ...purchases], roundStatus: 'shop' as const, roundWins: nextWins.opponent }
        cpuRef.current = next
        return next
      })
    }
  }, [buffCatalog])

  const finishRound = useCallback((outcome: 'win' | 'loss' | 'tie') => {
    if (status !== 'playing') return
    if (roundResolvedRef.current) return
    roundResolvedRef.current = true
    const key = `round-${roundIndex}-${outcome}`
    if (outcome === 'win' || outcome === 'tie') {
      awardXp(key, 'pvp-stadium-round-win-quarter-level')
    } else {
      awardXp(key, 'pvp-stadium-round-loss-tenth-level')
    }
    const nextWins = {
      player: roundWins.player + (outcome === 'win' ? 1 : 0),
      opponent: roundWins.opponent + (outcome === 'loss' ? 1 : 0),
    }
    addLog(
      outcome === 'win'
        ? `Round ${roundIndex} won.`
        : outcome === 'loss'
          ? `Round ${roundIndex} lost.`
          : `Round ${roundIndex} tied.`,
      outcome === 'loss' ? 'danger' : 'loot',
    )
    if (nextWins.player >= WIN_ROUNDS) {
      setRoundWins(nextWins)
      setStatus('won')
      setPlayerSide((current) => {
        const next = { ...current, roundStatus: 'won' as const, lastRoundOutcome: outcome, roundWins: nextWins.player }
        playerRef.current = next
        return next
      })
      awardXp('match-win', 'pvp-stadium-match-half-level')
      return
    }
    if (nextWins.opponent >= WIN_ROUNDS) {
      setRoundWins(nextWins)
      setStatus('lost')
      setPlayerSide((current) => {
        const next = { ...current, roundStatus: 'lost' as const, lastRoundOutcome: outcome, roundWins: nextWins.player }
        playerRef.current = next
        return next
      })
      return
    }
    beginShop(outcome, nextWins)
  }, [addLog, awardXp, beginShop, roundIndex, roundWins, status])

  useEffect(() => {
    if (status !== 'playing' || paused) return
    const timer = window.setInterval(() => {
      setElapsedTicks((value) => value + 1)
      if (!liveMatchRef.current) cpuTakeTurn()
      const nextPlayer = advanceBoss(playerRef.current)
      const nextCpu = liveMatchRef.current ? cpuRef.current : advanceBoss(cpuRef.current)
      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 && (!liveMatchRef.current && !nextCpuAlive)) finishRound('tie')
      else if (!nextPlayerAlive) finishRound('loss')
      else if (!liveMatchRef.current && !nextCpuAlive) finishRound('win')
    }, TICK_MS)
    return () => window.clearInterval(timer)
  }, [advanceBoss, cpuTakeTurn, finishRound, paused, status])

  const startNextRound = useCallback(() => {
    const nextRound = roundIndex + 1
    const nextPlayer = starterSide(partyTemplate, nextRound, playerRef.current.buffs, roundWins.player)
    const nextCpu = starterSide(cpuPartyTemplate, nextRound, cpuRef.current.buffs, roundWins.opponent)
    playerRef.current = nextPlayer
    cpuRef.current = nextCpu
    roundResolvedRef.current = false
    loggedOpponentRoundRef.current = ''
    setRoundIndex(nextRound)
    setPlayerSide(nextPlayer)
    setCpuSide(nextCpu)
    setSelectedTargetId(partyTemplate[0].id)
    setElapsedTicks(0)
    setShopReady(false)
    setShopPoints(0)
    addLog(`Round ${nextRound} starts. HP and mana restored.`, 'system')
    if (liveMatchRef.current) {
      publishPvpMatchState<StadiumSideState>(liveMatchRef.current.id, {
        state: nextPlayer,
        status: 'playing',
        stage: nextRound,
        encounterIndex: nextRound,
        encountersCleared: roundWins.player,
        enemyHealth: 0,
        alive: true,
        elapsedTicks: 0,
      }).catch(() => undefined)
    }
    beginRoundCountdown()
  }, [addLog, beginRoundCountdown, cpuPartyTemplate, partyTemplate, roundIndex, roundWins.opponent, roundWins.player, setSelectedTargetId])

  const finishShop = useCallback(() => {
    if (shopReady || status !== 'shop') return
    setShopReady(true)
    submittedShopRef.current = true
    setPlayerSide((current) => {
      const next = { ...current, shopReady: true }
      playerRef.current = next
      return next
    })
    if (liveMatchRef.current) {
      submitPvpUpgradeChoice(liveMatchRef.current.id, {
        encounterIndex: roundIndex,
        buffId: 'stadium-shop',
        debuffId: '',
        purchases: playerRef.current.buffs,
        shopReady: true,
      }).catch(() => undefined)
      return
    }
    startNextRound()
  }, [roundIndex, shopReady, startNextRound, status])

  useEffect(() => {
    if (status !== 'shop' || shopReady) return
    const updateTimer = () => {
      const remaining = Math.max(0, (shopEndsAtRef.current - Date.now()) / 1000)
      setShopTimeLeft(remaining)
      if (remaining <= 0) finishShop()
    }
    updateTimer()
    const timer = window.setInterval(updateTimer, 200)
    return () => window.clearInterval(timer)
  }, [finishShop, shopReady, status])

  useEffect(() => {
    if (!liveMatch || status === 'queueing') return
    let stopped = false
    const syncMatch = () => {
      publishPvpMatchState<StadiumSideState>(liveMatch.id, {
        state: playerRef.current,
        status: status === 'round-countdown' ? 'playing' : status,
        stage: roundIndex,
        encounterIndex: roundIndex,
        encountersCleared: roundWins.player,
        enemyHealth: 0,
        alive: playerRef.current.party.some((member) => member.health > 0),
        elapsedTicks,
      })
        .then((snapshot) => {
          if (stopped) return
          const opponentState = snapshot.states[liveMatch.opponentSide]
          if (opponentState && opponentState.roundIndex >= roundIndex) {
            cpuRef.current = opponentState
            setCpuSide(opponentState)
          }
          const opponentStatus = snapshot.statuses[liveMatch.opponentSide]
          if (opponentStatus === 'won' && status !== 'won' && status !== 'lost') setStatus('lost')
          if (opponentStatus === 'lost' && status !== 'won' && status !== 'lost') setStatus('won')
          if (!opponentState) return
          if (opponentState.roundIndex < roundIndex) return
          if (status === 'playing' && opponentState.roundIndex === roundIndex && opponentState.roundStatus === 'shop' && opponentState.lastRoundOutcome) {
            const key = `${roundIndex}-${opponentState.lastRoundOutcome}`
            if (loggedOpponentRoundRef.current === key) return
            loggedOpponentRoundRef.current = key
            if (opponentState.lastRoundOutcome === 'loss') finishRound('win')
            else if (opponentState.lastRoundOutcome === 'win') finishRound('loss')
            else finishRound('tie')
          }
          if (
            status === 'shop'
            && shopReady
            && (
              (opponentState.roundIndex === roundIndex && opponentState.shopReady)
              || opponentState.roundIndex > roundIndex
            )
          ) {
            startNextRound()
          }
        })
        .catch(() => undefined)
    }
    syncMatch()
    const timer = window.setInterval(syncMatch, 700)
    return () => {
      stopped = true
      window.clearInterval(timer)
    }
  }, [elapsedTicks, finishRound, liveMatch, roundIndex, roundWins.player, shopReady, startNextRound, status])

  const buyBuff = useCallback((buff: StadiumBuff) => {
    if (status !== 'shop' || shopReady || shopPoints < buff.cost) return
    let purchased = false
    setShopPoints((points) => {
      if (points < buff.cost) return points
      purchased = true
      return points - buff.cost
    })
    if (!purchased) return
    setPlayerSide((current) => {
      const next = { ...current, buffs: [...current.buffs, buff.id] }
      playerRef.current = next
      return next
    })
    addLog(`${buff.name} purchased.`, 'loot')
  }, [addLog, shopPoints, shopReady, status])

  const handleRematch = useCallback(() => {
    if (!liveMatch || rematchRequested) return
    let cancelled = false
    let attempts = 0
    setRematchRequested(true)
    setRematchMessage(`Waiting for ${liveMatch.opponentName} to rematch...`)
    const handleResponse = (result: PvpRematchResponse<StadiumSideState>) => {
      if (cancelled) return
      if (result.status === 'matched' && result.match && result.side) {
        startLiveMatch(result.match, result.side, `Rematch against ${liveMatch.opponentName} begins.`)
        return
      }
      attempts += 1
      if (attempts >= 180) {
        setRematchRequested(false)
        setRematchMessage('Rematch expired.')
        return
      }
      window.setTimeout(pollRematch, 700)
    }
    const pollRematch = () => {
      requestPvpRematch<StadiumSideState>(liveMatch.id)
        .then(handleResponse)
        .catch((reason: unknown) => {
          if (cancelled) return
          attempts += 1
          if (attempts >= 10) {
            setRematchRequested(false)
            setRematchMessage(reason instanceof Error ? reason.message : 'Unable to request rematch.')
            return
          }
          window.setTimeout(pollRematch, 900)
        })
    }
    pollRematch()
    return () => {
      cancelled = true
    }
  }, [liveMatch, rematchRequested, startLiveMatch])

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

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

  useGameAction((action) => {
    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.startsWith('ability')) {
      const spell = starterSpells.find((candidate) => candidate.key === action.slice('ability'.length))
      if (spell) castPlayerSpell(spell)
    }
  })

  const dualScreenState = useMemo<DualScreenCombatState>(() => ({
    difficultyName: 'Equalized iLvl 10',
    dungeonName: 'Stadium',
    contentName: 'Stadium',
    encounterName: 'Iron Arbiter',
    encounterDescription: 'Survive escalating arena pressure.',
    encounterHealth: 0,
    encounterMaxHealth: 1,
    encounterIsBoss: true,
    encounterIndex: roundIndex - 1,
    encounterCount: 5,
    party: playerSide.party,
    opponentName: opponentLabel,
    opponentClassName: liveMatch?.opponentClassName ?? (cpuDifficulty ? `CPU ${cpuDifficulty}` : 'CPU'),
    opponentParty: cpuSide.party,
    opponentResource: cpuSide.resource,
    opponentEnemyHealth: 0,
    opponentBuffSummary: summarizeStacks(cpuSide.buffs, buffCatalog),
    opponentDebuffSummary: `Dampening ${playerSide.dampeningPercent}%`,
    floatingTexts: floatingTexts
      .filter((entry) => entry.side === 'player')
      .map(({ id, memberId, value }) => ({ id, memberId, value })),
    partySize: playerSide.party.length,
    selectedId,
    log,
    status: status === 'queueing' || status === 'round-countdown' ? 'playing' : status === 'shop' ? 'upgrade-choice' : status,
    resource: playerSide.resource,
    maxResource: MAX_RESOURCE,
    resourceName: gameClass.resourceName,
    playerIsAlive: playerAlive,
    spells: starterSpells.map((spell, slotIndex) => ({
      ...spell,
      cost: spellResourceCost(spell, playerSide.buffs, playerSide.freeCastReady),
      slotIndex,
      remaining: playerSide.cooldowns[spell.id] ?? 0,
    })),
    activeDevice: lastDevice,
    bindings: bindings[lastDevice],
    controllerIconStyle,
    directPartyTargeting,
    paused,
    targetGroup: 0,
    speedMultiplier: 1,
    stadium: {
      dampeningPercent: playerSide.dampeningPercent,
      roundIndex,
      playerWins: roundWins.player,
      opponentWins: roundWins.opponent,
      survivalSeconds: playerSide.survivalSeconds,
      opponentSurvivalSeconds: cpuSide.survivalSeconds,
    },
  }), [
    bindings,
    buffCatalog,
    controllerIconStyle,
    cpuDifficulty,
    cpuSide.buffs,
    cpuSide.party,
    cpuSide.resource,
    cpuSide.survivalSeconds,
    directPartyTargeting,
    floatingTexts,
    gameClass.resourceName,
    lastDevice,
    liveMatch?.opponentClassName,
    log,
    opponentLabel,
    paused,
    playerAlive,
    playerSide.buffs,
    playerSide.cooldowns,
    playerSide.dampeningPercent,
    playerSide.freeCastReady,
    playerSide.party,
    playerSide.resource,
    playerSide.survivalSeconds,
    roundIndex,
    roundWins.opponent,
    roundWins.player,
    selectedId,
    starterSpells,
    status,
  ])
  useDualScreenPublisher(dualScreenState, dualScreenEnabled)

  const visibleBuffs = buffCatalog.filter((buff) => buff.category === shopCategory)
  const categoryLabel = shopCategory === 'misc' ? 'Miscellaneous' : slotLabel(shopCategory, starterSpells)

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

        {dualScreenEnabled && status !== 'queueing' && (
          <div className="dual-top-main stadium-dual-top">
            <header className="stadium-header">
              <div>
                <p className="eyebrow">Stadium</p>
                <h2>Round {roundIndex} / Best of 5</h2>
              </div>
              <strong>{roundWins.player} - {roundWins.opponent}</strong>
              <div>
                <p>Dampening {playerSide.dampeningPercent}%</p>
                <small>Survival {formatTime(playerSide.survivalSeconds)} | Equalized iLvl 10</small>
              </div>
            </header>

            <section className="stadium-pressure-panel">
              <strong>Iron Arbiter</strong>
              <span>No boss health | Next pulse in {Math.max(0, 5 - (elapsedTicks % 5) * (TICK_MS / 1000)).toFixed(1)}s</span>
            </section>

            <section className="dual-top-party">
              <div className="dual-top-party-grid">
                {playerSide.party.map((member, index) => {
                  const action = `targetParty${index + 1}` as InputAction
                  const targetBinding = directPartyTargeting ? bindings[lastDevice][action] : null
                  return (
                    <button
                      className={`dual-top-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
                      data-party-member-id={member.id}
                      key={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.ceil(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>
                      {targetBinding && (
                        <div className="member-target-key">
                          <ControllerBindingLabel
                            binding={targetBinding}
                            iconStyle={controllerIconStyle}
                          />
                        </div>
                      )}
                      <div className="member-effects">
                        {member.hotTicks > 0 && <span className="buff">Renew</span>}
                        {member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
                        {member.debuff && <span className="debuff">{member.debuff}</span>}
                      </div>
                    </button>
                  )
                })}
              </div>
            </section>

            <section className="dual-top-spell-strip">
              {starterSpells.map((spell, slotIndex) => {
                const remaining = playerSide.cooldowns[spell.id] ?? 0
                const cost = spellResourceCost(spell, playerSide.buffs, playerSide.freeCastReady)
                const percent = remaining > 0
                  ? Math.min(100, (remaining / Math.max(1, spell.cooldown)) * 100)
                  : 0
                return (
                  <button
                    className="dual-top-spell"
                    disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < cost || paused}
                    key={spell.id}
                    onClick={() => castPlayerSpell(spell)}
                    type="button"
                  >
                    <span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
                    {remaining > 0 && <i style={{ height: `${percent}%` }} />}
                    {remaining > 0 && <small>{remaining.toFixed(0)}</small>}
                    <span className="sr-only">{slotIndex + 1}. {spell.name}</span>
                  </button>
                )
              })}
              <div className="dual-top-resource">
                <strong>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</strong>
                <div className="bar mana-bar">
                  <span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} />
                </div>
              </div>
            </section>
          </div>
        )}

        {!dualScreenEnabled && status !== 'queueing' && (
          <div className="stadium-board">
            <header className="stadium-header">
              <div>
                <p className="eyebrow">Stadium</p>
                <h2>Round {roundIndex} / Best of 5</h2>
              </div>
              <strong>{roundWins.player} - {roundWins.opponent}</strong>
              <div>
                <p>Dampening {playerSide.dampeningPercent}%</p>
                <small>Survival {formatTime(playerSide.survivalSeconds)} | Equalized iLvl 10</small>
              </div>
            </header>

            <section className="stadium-pressure-panel">
              <strong>Iron Arbiter</strong>
              <span>No boss health | Next pulse in {Math.max(0, 5 - (elapsedTicks % 5) * (TICK_MS / 1000)).toFixed(1)}s</span>
            </section>

            <section className="combat-panel stadium-side">
              <div className="encounter-header">
                <div>
                  <p className="eyebrow">You</p>
                  <h2>{profile.character.name}</h2>
                </div>
                <div className="pvp-resource-wrap">
                  <span>{gameClass.resourceName} {Math.floor(playerSide.resource)} / {MAX_RESOURCE}</span>
                  <div className="bar mana-bar"><span style={{ width: `${(playerSide.resource / MAX_RESOURCE) * 100}%` }} /></div>
                </div>
              </div>
              <div className="party-grid pvp-party-grid">
                {playerSide.party.map((member) => (
                  <button className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'down' : ''}`} key={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>
                    </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}%` }} />}
                      <em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
                    </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</span>}
                      {member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
                      {member.debuff && <span className="debuff">{member.debuff}</span>}
                    </div>
                  </button>
                ))}
              </div>
              <p className="roguelike-upgrade-list">Buffs: {summarizeStacks(playerSide.buffs, buffCatalog)}</p>
            </section>

            <section className="combat-panel stadium-side">
              <div className="encounter-header">
                <div>
                  <p className="eyebrow">Opponent</p>
                  <h2>{opponentLabel}</h2>
                  <small>Survival {formatTime(cpuSide.survivalSeconds)}</small>
                </div>
                <div className="pvp-resource-wrap">
                  <span>{gameClass.resourceName} {Math.floor(cpuSide.resource)} / {MAX_RESOURCE}</span>
                  <div className="bar mana-bar"><span style={{ width: `${(cpuSide.resource / MAX_RESOURCE) * 100}%` }} /></div>
                </div>
              </div>
              <div className="party-grid pvp-party-grid">
                {cpuSide.party.map((member) => (
                  <div className={`party-member ${member.health <= 0 ? 'down' : ''}`} key={member.id}>
                    <div className="member-header">
                      <span className={`role role-${member.role.toLowerCase()}`}>{member.role[0]}</span>
                      <strong>{member.name}</strong>
                    </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}%` }} />}
                      <em className="health-text">{Math.floor(member.health)} / {effectiveMaxHealth(member)}</em>
                    </div>
                    <div className="member-effects">
                      {member.hotTicks > 0 && <span className="buff">Renew</span>}
                      {member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
                      {member.debuff && <span className="debuff">{member.debuff}</span>}
                    </div>
                  </div>
                ))}
              </div>
              <p className="roguelike-upgrade-list">Buffs: {summarizeStacks(cpuSide.buffs, buffCatalog)}</p>
            </section>

            <section className="pvp-bottom-spell-bar" aria-label="Player abilities">
              {starterSpells.map((spell) => {
                const remaining = playerSide.cooldowns[spell.id] ?? 0
                const cost = spellResourceCost(spell, playerSide.buffs, playerSide.freeCastReady)
                return (
                  <button
                    className="spell"
                    disabled={status !== 'playing' || !playerAlive || remaining > 0 || playerSide.resource < cost}
                    key={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>
                )
              })}
            </section>
          </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 === 'shop' && (
          <div className="result-screen">
            <div className="stadium-shop-dialog">
              <div className="pvp-upgrade-header">
                <div>
                  <p className="eyebrow">Stadium Buy Round</p>
                  <h2>Round {roundIndex} Complete</h2>
                </div>
                <strong>{shopTimeLeft.toFixed(0)}s</strong>
              </div>
              <div className="stadium-shop-summary">
                <span>Points: {shopPoints}</span>
                <span>Score: {roundWins.player} - {roundWins.opponent}</span>
                {shopReady && <span>Waiting for opponent...</span>}
              </div>
              <div className="stadium-shop-layout">
                <nav className="stadium-shop-tabs">
                  {(['1', '2', '3', '4', '5'] as SlotKey[]).map((slot) => (
                    <button className={shopCategory === slot ? 'active' : ''} key={slot} onClick={() => setShopCategory(slot)} type="button">
                      {slotLabel(slot, starterSpells)}
                    </button>
                  ))}
                  <button className={shopCategory === 'misc' ? 'active' : ''} onClick={() => setShopCategory('misc')} type="button">
                    Miscellaneous
                  </button>
                </nav>
                <section>
                  <h3>{categoryLabel} Buffs</h3>
                  <div className="stadium-shop-grid">
                    {visibleBuffs.map((buff) => (
                      <button disabled={shopReady || shopPoints < buff.cost} key={buff.id} onClick={() => buyBuff(buff)} type="button">
                        <strong>[{buff.cost}] {buff.name}</strong>
                        <small>{buff.description}</small>
                      </button>
                    ))}
                  </div>
                  <p>Active: {summarizeStacks(playerSide.buffs, buffCatalog)}</p>
                </section>
              </div>
              <button className="secondary-result-button" disabled={shopReady} onClick={finishShop} type="button">
                {shopReady ? 'Finished' : 'Finished Buying'}
              </button>
            </div>
          </div>
        )}

        {paused && (
          <div className="pause-screen">
            <div>
              <p className="eyebrow">Paused</p>
              <h2>Stadium</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' ? 'Stadium Won' : `${opponentLabel} Wins`}</h2>
              <p>Final score {roundWins.player} - {roundWins.opponent}</p>
              <div className="reward-summary">
                <p>+{rewardSummary.experienceGained} XP</p>
                {rewardError && <p className="reward-error">{rewardError}</p>}
                {rewardSummary.levelsGained > 0 && rewardSummary.previousLevel !== null && rewardSummary.newLevel !== null && (
                  <p className="level-gain">
                    Level {rewardSummary.previousLevel} to {rewardSummary.newLevel}
                    <small>+{rewardSummary.talentPointsGained} talent point{rewardSummary.talentPointsGained === 1 ? '' : 's'}</small>
                  </p>
                )}
                {rewardSummary.unlockedAbilities.map((ability) => (
                  <p className="ability-unlock" key={ability.id}>
                    <span>{ability.glyph}</span>
                    Ability Unlocked: {ability.name}
                  </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>
                  )}
                </>
              )}
              {liveMatch && (
                <>
                  <button disabled={rematchRequested} onClick={handleRematch} type="button">
                    {rematchRequested ? 'Waiting for Rematch' : 'Rematch'}
                  </button>
                  {rematchMessage && <p>{rematchMessage}</p>}
                </>
              )}
              <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>
  )
}
