import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
  completeDungeon,
  completeRoguelike,
  loadProfile,
  type DungeonReward,
  rollEncounterLoot,
  type LootRoll,
} from '../profile'
import {
  INITIAL_PARTY,
  RAID_PARTY,
  DEFAULT_GROUP_HEAL_TARGETS,
  groupHealTargets,
  partyDamageOutput,
  tankPressureTargets,
  type CombatLogEntry,
  type PartyMember,
  type Spell,
} from '../game'
import type {
  Ability,
  CharacterProfile,
  Difficulty,
  Dungeon,
  DungeonEncounter,
} from '../profile'
import {
  useGameAction,
  useInput,
  type InputAction,
} from '../input'
import { ControllerBindingLabel } from './ControllerIcons'
import {
  DualScreenTopCombat,
  useDualScreen,
  useDualScreenPublisher,
  type DualScreenCombatState,
} from '../dualScreen'

const TICK_MS = 700

type RoguelikeMode = 'dungeon' | 'raid'
type RoguelikeUpgradeTiming = 'boss' | 'encounter'
type RoguelikeAbilityLabelMode = 'ability' | 'slot'
type SlotKey = '1' | '2' | '3' | '4' | '5' | '6'
type RoguelikeMechanic =
  | 'party-pulse'
  | 'searing-mark'
  | 'max-health-cut'
  | 'healing-reduction'
  | 'tank-buster'
  | 'resource-drain'
  | 'ramping-poison'

type RoguelikeEncounter = DungeonEncounter & {
  roguelikeMechanics?: RoguelikeMechanic[]
}

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

type RoguelikeUpgrade = {
  id: RoguelikeUpgradeId
  name: string
  description: string
}

type FloatingCombatText = {
  id: number
  memberId: string
  value: number
}

type SinglePlayerCombatState = {
  party: PartyMember[]
  resource: number
  enemyHealth: number
  cooldowns: Record<string, number>
  elapsedTicks: number
  castsTowardFree: number
  freeCastReady: boolean
}

const ROGUELIKE_MECHANICS: RoguelikeMechanic[] = [
  'party-pulse',
  'searing-mark',
  'max-health-cut',
  'healing-reduction',
  'tank-buster',
  'resource-drain',
  'ramping-poison',
]

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

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

function healAmount(member: PartyMember, amount: number, multiplier = 1) {
  return Math.round(amount * (member.healingReductionTicks && member.healingReductionTicks > 0 ? 0.75 : 1) * multiplier)
}

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

function memberHotEffects(member: PartyMember) {
  if (member.hotEffects?.length) return member.hotEffects
  return member.hotTicks > 0
    ? [{ id: 'legacy-renew', spellId: 'legacy-renew', label: 'Renew', ticks: member.hotTicks, power: 6 }]
    : []
}

function effectId(prefix: string) {
  return `${prefix}-${globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}`}`
}

function addHotEffect(member: PartyMember, spell: Spell, ticks = 5) {
  const nextEffect = {
    id: effectId(spell.id),
    spellId: spell.id,
    label: spell.name,
    ticks,
    power: Math.max(1, Math.round(spell.power / 2)),
  }
  const currentEffects = memberHotEffects(member).filter((effect) => effect.spellId !== spell.id)
  return [...currentEffects, nextEffect]
}

function addBounceHeal(member: PartyMember, spell: Spell) {
  return [
    ...(member.bounceHeals ?? []),
    {
      id: effectId(spell.id),
      label: spell.name,
      charges: 4,
      power: spell.power,
    },
  ]
}

function tickHotEffects(effects: PartyMember['hotEffects']) {
  return (effects ?? [])
    .map((effect) => ({ ...effect, ticks: effect.ticks - 1 }))
    .filter((effect) => effect.ticks > 0)
}

function upgradeStackCount(upgrades: RoguelikeUpgrade[], id: RoguelikeUpgradeId) {
  return upgrades.filter((upgrade) => upgrade.id === id).length
}

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

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

function summarizeUpgradeStacks(
  upgrades: RoguelikeUpgrade[],
  catalog: RoguelikeUpgrade[],
) {
  const counts = new Map<RoguelikeUpgradeId, number>()
  upgrades.forEach((upgrade) => counts.set(upgrade.id, (counts.get(upgrade.id) ?? 0) + 1))
  return Array.from(counts.entries())
    .map(([id, count]) => {
      const name = catalog.find((upgrade) => upgrade.id === id)?.name ?? id
      return count > 1 ? `${name} x${count}` : name
    })
    .join(', ')
}

function cooldownMultiplier(spell: Spell, upgrades: RoguelikeUpgrade[]) {
  return 0.75 ** upgradeStackCount(upgrades, `slot${spell.key as SlotKey}-cooldown-down` as RoguelikeUpgradeId)
}

function spellResourceCost(spell: Spell, upgrades: RoguelikeUpgrade[], freeCastReady: boolean) {
  const adjustedCost = Math.ceil(
    spell.cost * (0.75 ** upgradeStackCount(upgrades, `slot${spell.key as SlotKey}-cost-down` as RoguelikeUpgradeId)),
  )
  return freeCastReady && upgradeStackCount(upgrades, 'fifth-cast-free') > 0 ? 0 : adjustedCost
}

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

function getCurrentPart(encounterIndex: number) {
  return Math.floor(encounterIndex / 3) + 1
}

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

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

function mechanicLabel(mechanic: RoguelikeMechanic) {
  const labels: Record<RoguelikeMechanic, string> = {
    'party-pulse': 'party pulse',
    'searing-mark': 'damage mark',
    'max-health-cut': 'max health cut',
    'healing-reduction': 'healing reduction',
    'tank-buster': 'tank buster',
    'resource-drain': 'resource drain',
    'ramping-poison': 'ramping poison',
  }
  return labels[mechanic]
}

function makeRoguelikeSegment(
  pool: DungeonEncounter[],
  stage: number,
  difficulty: Difficulty,
  mode: RoguelikeMode,
): RoguelikeEncounter[] {
  const encounterThreat = (encounter: DungeonEncounter) => (
    encounter.maxHealth
    + encounter.damage * 18
    + encounter.tankDamage * 10
    + encounter.partyDamage * 18
  )
  const trashPool = [...pool.filter((encounter) => !encounter.isBoss)]
    .sort((left, right) => encounterThreat(left) - encounterThreat(right))
  const bossPool = [...pool.filter((encounter) => encounter.isBoss)]
    .sort((left, right) => encounterThreat(left) - encounterThreat(right))
  const trashCandidateCount = Math.min(trashPool.length, 4 + stage * (mode === 'raid' ? 1 : 2))
  const bossCandidateCount = Math.min(bossPool.length, 2 + Math.floor((stage + 1) / 2))
  const selectedTrash = chooseRandom(trashPool.slice(0, trashCandidateCount), 2)
  const selectedBoss = chooseRandom(bossPool.slice(0, bossCandidateCount), 1)[0] ?? trashPool[0] ?? pool[0]
  const healthScale = 0.64 + stage * (mode === 'raid' ? 0.15 : 0.11)
  const damageScale = 0.58 + stage * (mode === 'raid' ? 0.13 : 0.1)
  const mechanics = chooseRandom(ROGUELIKE_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
  return [...selectedTrash, selectedBoss].map((encounter, index) => {
    const isBoss = index === 2
    return {
      ...encounter,
      id: 900000 + stage * 10 + index,
      sequence: (stage - 1) * 3 + index + 1,
      isBoss,
      encounterType: isBoss ? 'boss' : 'trash',
      enemyName: isBoss ? `${encounter.enemyName} ${stage}` : encounter.enemyName,
      description: isBoss
        ? `Roguelike boss with ${mechanics.map(mechanicLabel).join(', ')}.`
        : encounter.description,
      maxHealth: Math.round(encounter.maxHealth * difficulty.healthMultiplier * healthScale),
      damage: Math.round(encounter.damage * difficulty.damageMultiplier * damageScale),
      tankDamage: Math.round(encounter.tankDamage * difficulty.damageMultiplier * damageScale),
      partyDamage: Math.round(encounter.partyDamage * (0.9 + stage * 0.05)),
      lootTables: [],
      roguelikeMechanics: isBoss ? mechanics : [],
    }
  })
}

export function CombatScreen({
  difficulty,
  dungeon,
  hardMode = false,
  profile,
  startPart = 1,
  roguelikeMode,
  roguelikeUpgradeTiming = 'boss',
  roguelikeAbilityLabelMode = 'ability',
  roguelikeEncounterPool,
  onExit,
  onProfileUpdated,
}: {
  difficulty: Difficulty
  dungeon: Dungeon
  hardMode?: boolean
  profile: CharacterProfile
  startPart?: number
  roguelikeMode?: RoguelikeMode
  roguelikeUpgradeTiming?: RoguelikeUpgradeTiming
  roguelikeAbilityLabelMode?: RoguelikeAbilityLabelMode
  roguelikeEncounterPool?: DungeonEncounter[]
  onExit: () => void
  onProfileUpdated: (profile: CharacterProfile) => void
}) {
  const staticEncounters = useMemo(
    () => dungeon.encounters.map((encounter) => ({
      ...encounter,
      maxHealth: Math.round(encounter.maxHealth * difficulty.healthMultiplier),
      damage: Math.round(encounter.damage * difficulty.damageMultiplier),
      tankDamage: Math.round(encounter.tankDamage * difficulty.damageMultiplier),
    })),
    [difficulty.damageMultiplier, difficulty.healthMultiplier, dungeon.encounters],
  )
  const isRoguelike = Boolean(roguelikeMode)
  const roguelikePool = roguelikeEncounterPool ?? dungeon.encounters
  const [roguelikeStage, setRoguelikeStage] = useState(1)
  const [roguelikeEncounters, setRoguelikeEncounters] = useState<RoguelikeEncounter[]>(() =>
    roguelikeMode ? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode) : [],
  )
  const encounters = isRoguelike ? roguelikeEncounters : staticEncounters
  const gameClass = profile.classes.find(
    (candidate) => candidate.id === profile.character.classId,
  )!
  const healingPower = isRoguelike ? 0 : profile.gearStats.healingPower
  const spells = profile.abilitySlots.flatMap((abilityId, index) => {
    const ability = gameClass.spells.find((candidate) => candidate.id === abilityId)
    return ability
      ? [toCombatSpell(ability, String(index + 1), healingPower)]
      : []
  })
  const roguelikeUpgradeCatalog = useMemo(
    () => buildRoguelikeUpgrades(spells, roguelikeAbilityLabelMode),
    [roguelikeAbilityLabelMode, spells],
  )
  const maxResource = gameClass.maxResource + (isRoguelike ? 0 : profile.gearStats.maxResourceBonus)
  const partyTemplate = useMemo(
    () => (dungeon.partySize >= 10 ? RAID_PARTY : INITIAL_PARTY).map((member) => ({
      ...member,
      name: member.id === 'mira' ? profile.character.name : member.name,
    })),
    [dungeon.partySize, profile.character.name],
  )
  const sectionName = isRoguelike ? 'Stage' : dungeon.contentType === 'raid' ? 'Phase' : 'Part'
  const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
  const initialEncounterIndex = (startPart - 1) * 3
  const enemyCount = hardMode ? 2 : 1
  const initialCombatState = useMemo<SinglePlayerCombatState>(() => ({
    party: partyTemplate,
    resource: maxResource,
    enemyHealth: encounters[initialEncounterIndex].maxHealth * enemyCount,
    cooldowns: {},
    elapsedTicks: 0,
    castsTowardFree: 0,
    freeCastReady: false,
  }), [encounters, enemyCount, initialEncounterIndex, maxResource, partyTemplate])
  const [combatState, setCombatState] = useState<SinglePlayerCombatState>(() => initialCombatState)
  const [selectedId, setSelectedId] = useState(partyTemplate[0].id)
  const [encounterIndex, setEncounterIndex] = useState(initialEncounterIndex)
  const [status, setStatus] = useState<'playing' | 'won' | 'lost' | 'part-complete' | 'upgrade-choice'>('playing')
  const [paused, setPaused] = useState(false)
  const [speedMultiplier, setSpeedMultiplier] = useState<1 | 2>(1)
  const [targetGroup, setTargetGroup] = useState<0 | 1 | 2>(0)
  const [log, setLog] = useState<CombatLogEntry[]>([
    { id: 1, text: `${dungeon.name} begins.`, tone: 'system' },
  ])
  const [reward, setReward] = useState<DungeonReward | null>(null)
  const [rewardError, setRewardError] = useState('')
  const [lootRolls, setLootRolls] = useState<LootRoll[]>([])
  const [showEndLog, setShowEndLog] = useState(false)
  const [floatingTexts, setFloatingTexts] = useState<FloatingCombatText[]>([])
  const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
  const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
  const rewardClaimedRef = useRef(false)
  const profileRefreshedRef = useRef(false)
  const rolledEncounterIdsRef = useRef(new Set<string>())
  const runTokenRef = useRef(crypto.randomUUID())
  const resourceSpentRef = useRef(0)
  const runStartedAtRef = useRef(0)
  const partStartTimesRef = useRef<Record<number, number>>({})
  const nextLogId = useRef(2)
  const nextFloatingTextId = useRef(1)
  const combatRef = useRef(initialCombatState)
  const selectedIdRef = useRef(partyTemplate[0].id)
  const runCombatTickRef = useRef<() => void>(() => {})
  const combatClockActiveRef = useRef(false)
  const lastCombatTickAtRef = useRef(performance.now())
  const statusRef = useRef(status)
  const pausedRef = useRef(paused)
  const speedMultiplierRef = useRef<1 | 2>(speedMultiplier)
  const { party, resource, enemyHealth, cooldowns, freeCastReady } = combatState
  const encounter = encounters[encounterIndex]
  const encounterMaxHealth = encounter.maxHealth * enemyCount
  const currentPart = getCurrentPart(encounterIndex)
  const completedSections = dungeon.contentType === 'raid'
    ? profile.completedRaidPhases
    : profile.completedDungeonParts
  const canContinueAfterPart = !hardMode || completedSections >= currentPart + 1
  const firstEncounterIndex = (startPart - 1) * 3
  const expectedLootRolls = encounters
    .slice(firstEncounterIndex, encounterIndex + 1)
    .filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
    .length * enemyCount
  const isPartBoss = encounter.isBoss && encounterIndex % 3 === 2
  const isFinalBoss = isPartBoss && encounterIndex === encounters.length - 1
  const playerHealer = party.find((member) => member.id === 'mira')
  const playerIsAlive = Boolean(playerHealer && playerHealer.health > 0)
  const upgradesEveryEncounter = roguelikeUpgradeTiming === 'encounter'
  const activeEffects = useMemo(
    () => {
      const effects = new Set<string>(
        gameClass.talents
          .filter((talent) => talent.rank > 0)
          .map((talent) => talent.effectType),
      )
      if (!isRoguelike) {
        profile.setBonuses
          .filter((bonus) => bonus.active)
          .forEach((bonus) => effects.add(bonus.effectType))
      }
      return effects
    },
    [gameClass.talents, isRoguelike, profile.setBonuses],
  )
  const {
    bindings,
    controllerIconStyle,
    directPartyTargeting,
    lastDevice,
  } = useInput()
  const {
    enabled: dualScreenEnabled,
  } = useDualScreen()

  statusRef.current = status
  pausedRef.current = paused
  speedMultiplierRef.current = speedMultiplier

  useEffect(() => {
    const now = Date.now()
    runStartedAtRef.current = now
    partStartTimesRef.current = { [startPart]: now }
  }, [startPart])

  useEffect(() => {
    if (!paused) return
    window.requestAnimationFrame(() => {
      document.querySelector<HTMLButtonElement>('.pause-screen button')?.focus({ preventScroll: true })
    })
  }, [paused])

  const setCombat = useCallback((
    nextState: SinglePlayerCombatState | ((current: SinglePlayerCombatState) => SinglePlayerCombatState),
  ) => {
    const next = typeof nextState === 'function'
      ? nextState(combatRef.current)
      : nextState
    combatRef.current = next
    setSelectedId(selectedIdRef.current)
    setCombatState(next)
  }, [])

  const syncSelectedTargetDom = useCallback((id: string) => {
    document.querySelectorAll<HTMLButtonElement>('[data-party-member-id]').forEach((button) => {
      const selected = button.dataset.partyMemberId === id
      button.classList.toggle('selected', selected)
      button.setAttribute('aria-pressed', String(selected))
    })
  }, [])

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

  useEffect(() => {
    syncSelectedTargetDom(selectedIdRef.current)
  }, [combatState, syncSelectedTargetDom])

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

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

  const requestLootRoll = useCallback(
    (encounterId: number, rollIndex = 0) => {
      const rollKey = `${encounterId}:${rollIndex}`
      if (rolledEncounterIdsRef.current.has(rollKey)) return
      rolledEncounterIdsRef.current.add(rollKey)
      const runToken = rollIndex === 0 ? runTokenRef.current : `${runTokenRef.current}-hard-${rollIndex}`
      rollEncounterLoot(encounterId, difficulty.id, runToken)
        .then((result) => {
          setLootRolls((current) => [...current, result])
          const awarded = result.items
            .map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`)
            .join(', ')
          addLog(
            result.dropped && awarded
              ? `${result.encounterName} awarded ${awarded}.`
              : `${result.encounterName} dropped no components.`,
            result.dropped ? 'loot' : 'system',
          )
        })
        .catch((reason: unknown) => {
          addLog(
            reason instanceof Error ? reason.message : 'The loot roll failed.',
            'danger',
          )
        })
    },
    [addLog, difficulty.id],
  )

  const resetRun = useCallback(() => {
    const nextRoguelikeEncounters = roguelikeMode
      ? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode)
      : []
    const nextEncounters = roguelikeMode ? nextRoguelikeEncounters : staticEncounters
    const freshParty = partyTemplate.map((member) => ({ ...member }))
    setCombat({
      party: freshParty,
      resource: maxResource,
      enemyHealth: nextEncounters[initialEncounterIndex].maxHealth * enemyCount,
      cooldowns: {},
      elapsedTicks: 0,
      castsTowardFree: 0,
      freeCastReady: false,
    })
    if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
    setRoguelikeStage(1)
    setSelectedTargetId(partyTemplate[0].id)
    setEncounterIndex(initialEncounterIndex)
    setStatus('playing')
    setPaused(false)
    setTargetGroup(0)
    setReward(null)
    setRewardError('')
    setLootRolls([])
    setShowEndLog(false)
    setFloatingTexts([])
    setRoguelikeUpgrades([])
    setUpgradeChoices([])
    rewardClaimedRef.current = false
    profileRefreshedRef.current = false
    rolledEncounterIdsRef.current = new Set()
    runTokenRef.current = crypto.randomUUID()
    resourceSpentRef.current = 0
    runStartedAtRef.current = Date.now()
    partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
    setLog([{ id: nextLogId.current++, text: 'A new run begins.', tone: 'system' }])
  }, [difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])

  const castSpell = useCallback(
    (spell: Spell) => {
      const current = combatRef.current
      const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, current.freeCastReady)
      if (status !== 'playing' || current.cooldowns[spell.id] > 0 || current.resource < effectiveCost) return
      const healer = current.party.find((member) => member.id === 'mira')
      if (!healer || healer.health <= 0) return
      const targetId = selectedIdRef.current
      const selected = current.party.find((member) => member.id === targetId)
      if (!selected || selected.health <= 0) return
      const extraTarget = (blockedIds: string[]) => current.party
        .filter((member) => member.health > 0 && !blockedIds.includes(member.id))
        .sort((left, right) => (left.health / left.maxHealth) - (right.health / right.maxHealth))[0]
      const effectSpell = (name: string) => {
        const ability = gameClass.spells.find((candidate) => candidate.name === name)
        return ability ? toCombatSpell(ability, `effect-${ability.id}`, healingPower) : null
      }
      const renewEffect = effectSpell('Renew')
      const shieldEffect = effectSpell('Sun Ward')
      const healingMultiplier = (member: PartyMember) =>
        activeEffects.has('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
      const directTargets = new Set([targetId])
      const hotTargets = new Set<string>()
      const shieldTargets = new Set<string>()
      const extraTargets = upgradeStackCount(roguelikeUpgrades, `slot${spell.key as SlotKey}-extra-target` as RoguelikeUpgradeId)
      const groupTargets = new Set(
        spell.kind === 'group'
          ? groupHealTargets(current.party, DEFAULT_GROUP_HEAL_TARGETS + extraTargets).map((member) => member.id)
          : [],
      )
      if (spell.kind === 'hot' || spell.effectType === 'direct_hot') hotTargets.add(targetId)
      if (spell.kind === 'shield') shieldTargets.add(targetId)
      if (spell.name === 'Mend' && activeEffects.has('mend_extra_target')) {
        const extra = extraTarget([targetId])
        if (extra) directTargets.add(extra.id)
      }
      if (spell.name === 'Renew' && activeEffects.has('renew_extra_target')) {
        const extra = extraTarget([targetId])
        if (extra) hotTargets.add(extra.id)
      }
      if (spell.name === 'Mend' && activeEffects.has('mend_applies_renew')) {
        hotTargets.add(targetId)
      }
      for (let index = 0; index < extraTargets; index += 1) {
        if (spell.kind === 'group') break
        if (spell.kind === 'hot') {
          const extra = extraTarget([...hotTargets])
          if (extra) hotTargets.add(extra.id)
          continue
        }
        if (spell.kind === 'shield') {
          const extra = extraTarget([...shieldTargets])
          if (extra) shieldTargets.add(extra.id)
          continue
        }
        const extra = extraTarget([...directTargets])
        if (extra) directTargets.add(extra.id)
      }

      const nextParty = current.party.map((member) => {
        if (member.health <= 0) return member
        if (spell.kind === 'group') {
          if (!groupTargets.has(member.id)) return member
          if (spell.effectType === 'party_absorb') {
            const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
            return { ...member, shield: Math.max(member.shield, power) }
          }
          if (spell.effectType === 'party_hot') {
            return {
              ...member,
              hotTicks: 0,
              hotEffects: addHotEffect(member, spell),
            }
          }
          const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'group-heal-boost')))
          const nextHealth = healMember(member, power, healingMultiplier(member))
          addFloatingHeal(member.id, Math.max(0, nextHealth - member.health))
          const nextShield = spell.name === 'Radiance' && activeEffects.has('radiance_applies_shield')
            ? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.3))
            : member.shield
          return {
            ...member,
            health: nextHealth,
            shield: nextShield,
            hotTicks: spell.name === 'Radiance' && activeEffects.has('radiance_applies_renew') ? 0 : member.hotTicks,
            hotEffects: spell.name === 'Radiance' && activeEffects.has('radiance_applies_renew') && renewEffect
              ? addHotEffect(member, renewEffect, 3)
              : member.hotEffects,
          }
        }
        if (
          !directTargets.has(member.id)
          && !hotTargets.has(member.id)
          && !shieldTargets.has(member.id)
          && !(member.id === targetId && (spell.kind === 'damage_reduction' || spell.kind === 'bounce_heal'))
        ) return member
        if (spell.kind === 'shield') {
          const power = Math.round(spell.power * (1.25 ** upgradeStackCount(roguelikeUpgrades, 'shield-boost')))
          return {
            ...member,
            hotTicks: activeEffects.has('shield_applies_renew') && renewEffect ? 0 : member.hotTicks,
            hotEffects: activeEffects.has('shield_applies_renew') && renewEffect
              ? addHotEffect(member, renewEffect)
              : member.hotEffects,
            shield: Math.max(member.shield, power),
          }
        }
        if (spell.kind === 'damage_reduction') {
          return { ...member, damageReductionTicks: 12 }
        }
        if (spell.kind === 'bounce_heal') {
          return { ...member, bounceHeals: addBounceHeal(member, spell) }
        }
        if (spell.kind === 'cleanse') {
          return {
            ...member,
            health: healMember(member, spell.power, healingMultiplier(member)),
            debuff: undefined,
            debuffTicks: undefined,
            poisonStacks: undefined,
            maxHealthPenaltyTicks: undefined,
            healingReductionTicks: undefined,
          }
        }
        const nextHealth = directTargets.has(member.id)
          ? healMember(member, spell.power, healingMultiplier(member))
          : member.health
        if (nextHealth > member.health) addFloatingHeal(member.id, nextHealth - member.health)
        const nextShield = spell.name === 'Mend' && directTargets.has(member.id) && activeEffects.has('mend_applies_shield')
          ? Math.max(member.shield, Math.round((shieldEffect?.power ?? spell.power) * 0.5))
          : member.shield
        const appliedHotSpell = spell.name === 'Mend' && activeEffects.has('mend_applies_renew') && renewEffect
          ? renewEffect
          : spell
        return {
          ...member,
          health: nextHealth,
          shield: nextShield,
          hotTicks: 0,
          hotEffects: hotTargets.has(member.id)
            ? addHotEffect(member, appliedHotSpell)
            : member.hotEffects,
        }
      })
      const freeCastStacks = upgradeStackCount(roguelikeUpgrades, 'fifth-cast-free')
      const nextFreeCastReady = freeCastStacks > 0 && current.freeCastReady
        ? false
        : current.freeCastReady
      const nextCastsTowardFree = freeCastStacks > 0
        ? current.freeCastReady
          ? 0
          : current.castsTowardFree + 1 >= 5
            ? 0
            : current.castsTowardFree + 1
        : current.castsTowardFree
      const gainedFreeCast = freeCastStacks > 0
        && !current.freeCastReady
        && current.castsTowardFree + 1 >= 5
      resourceSpentRef.current += effectiveCost
      const nextCooldowns = {
        ...current.cooldowns,
      }
      if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
        const radiance = spells.find((candidate) => candidate.name === 'Radiance')
        if (radiance) nextCooldowns[radiance.id] = Math.max(0, (nextCooldowns[radiance.id] ?? 0) - 2)
      }
      nextCooldowns[spell.id] = spell.cooldown * cooldownMultiplier(spell, roguelikeUpgrades)

      setCombat({
        ...current,
        party: nextParty,
        resource: current.resource - effectiveCost,
        cooldowns: nextCooldowns,
        castsTowardFree: nextCastsTowardFree,
        freeCastReady: gainedFreeCast || nextFreeCastReady,
      })
      addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
    },
    [activeEffects, addFloatingHeal, addLog, gameClass.spells, healingPower, roguelikeUpgrades, setCombat, spells, status],
  )

  const finishRun = useCallback(
    (completedPart: number, runStartPart: number) => {
      if (rewardClaimedRef.current) return
      rewardClaimedRef.current = true
      const now = Date.now()
      const pTimes = partStartTimesRef.current
      const partDuration = (part: number) => {
        const start = pTimes[part]
        if (!start) return 0
        const next = pTimes[part + 1] ?? now
        return Math.max(1, Math.round((next - start) / 1000))
      }
      completeDungeon(
        dungeon.id,
        difficulty.id,
        resourceSpentRef.current,
        Math.max(1, Math.round((now - runStartedAtRef.current) / 1000)),
        completedPart,
        runStartPart,
        [partDuration(1), partDuration(2), partDuration(3)],
        hardMode,
      )
        .then((result) => {
          setReward(result)
          onProfileUpdated(result.profile)
          setStatus('won')
        })
        .catch((reason: unknown) => {
          setRewardError(
            reason instanceof Error ? reason.message : 'Unable to award experience.',
          )
        })
    },
    [difficulty.id, dungeon.id, hardMode, onProfileUpdated],
  )

  const finishRoguelikeRun = useCallback(
    (encountersCleared: number) => {
      if (rewardClaimedRef.current) return
      rewardClaimedRef.current = true
      completeRoguelike(
        dungeon.id,
        difficulty.id,
        encountersCleared,
        resourceSpentRef.current,
        Math.max(1, Math.round((Date.now() - runStartedAtRef.current) / 1000)),
      )
        .then((result) => {
          setReward(result)
          onProfileUpdated(result.profile)
        })
        .catch((reason: unknown) => {
          setRewardError(
            reason instanceof Error ? reason.message : 'Unable to award roguelike experience.',
          )
        })
    },
    [difficulty.id, dungeon.id, onProfileUpdated],
  )

  const selectRelativeTarget = useCallback((direction: -1 | 1) => {
    const living = combatRef.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 columns = dungeon.partySize >= 10 ? 6 : 3
    const currentIndex = combatRef.current.party.findIndex((member) => member.id === selectedIdRef.current)
    if (currentIndex < 0) {
      setSelectedTargetId(combatRef.current.party[0].id)
      return
    }
    const currentRow = Math.floor(currentIndex / columns)
    const currentColumn = currentIndex % columns
    const candidates = combatRef.current.party
      .map((member, index) => ({
        member,
        index,
        row: Math.floor(index / columns),
        column: index % columns,
      }))
      .filter(({ index, row, column }) => {
        if (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 aPrimary = action === 'navigateLeft' || action === 'navigateRight'
          ? Math.abs(a.column - currentColumn)
          : Math.abs(a.row - currentRow)
        const bPrimary = action === 'navigateLeft' || action === 'navigateRight'
          ? Math.abs(b.column - currentColumn)
          : Math.abs(b.row - currentRow)
        const aSecondary = action === 'navigateLeft' || action === 'navigateRight'
          ? 0
          : Math.abs(a.column - currentColumn)
        const bSecondary = action === 'navigateLeft' || action === 'navigateRight'
          ? 0
          : Math.abs(b.column - currentColumn)
        return aPrimary - bPrimary || aSecondary - bSecondary
      })
    if (candidates[0]) setSelectedTargetId(candidates[0].member.id)
  }, [dungeon.partySize, setSelectedTargetId])

  const selectDirectTarget = useCallback((slot: number) => {
    const index = slot + (dungeon.partySize > 6 ? targetGroup * 6 : 0)
    const member = combatRef.current.party[index]
    if (member) setSelectedTargetId(member.id)
  }, [dungeon.partySize, setSelectedTargetId, targetGroup])

  const chooseRoguelikeUpgrade = useCallback((upgrade: RoguelikeUpgrade) => {
    if (!roguelikeMode) return
    const current = combatRef.current
    const clearedBoss = encounters[encounterIndex]?.isBoss ?? false
    const recoveredParty = current.party.map((member) => ({
      ...member,
      health: member.health <= 0
        ? 0
        : clamp(member.health + Math.round(member.maxHealth * 0.35), 0, member.maxHealth),
      debuff: undefined,
      debuffTicks: undefined,
      poisonStacks: undefined,
      maxHealthPenaltyTicks: undefined,
      healingReductionTicks: undefined,
      hotEffects: [],
      bounceHeals: [],
      damageReductionTicks: undefined,
    }))
    const nextStage = clearedBoss ? roguelikeStage + 1 : roguelikeStage
    const nextSegment = clearedBoss
      ? makeRoguelikeSegment(roguelikePool, nextStage, difficulty, roguelikeMode)
      : []
    const nextEncounter = clearedBoss
      ? nextSegment[0]
      : encounters[encounterIndex + 1]
    if (!nextEncounter) return
    setRoguelikeUpgrades((current) => [...current, upgrade])
    if (clearedBoss) {
      setRoguelikeStage(nextStage)
      setRoguelikeEncounters((current) => [...current, ...nextSegment])
    }
    setEncounterIndex((current) => current + 1)
    setCombat({
      ...current,
      party: recoveredParty,
      enemyHealth: nextEncounter.maxHealth * enemyCount,
      elapsedTicks: 0,
      cooldowns: {},
      resource: clamp(current.resource + Math.round(maxResource * 0.25), 0, maxResource),
    })
    setUpgradeChoices([])
    setStatus('playing')
    addLog(`${upgrade.name} gained. ${nextEncounter.enemyName} approaches.`, 'system')
  }, [addLog, difficulty, encounterIndex, encounters, enemyCount, maxResource, roguelikeMode, roguelikePool, roguelikeStage, setCombat])

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

  const runCombatTick = useCallback(() => {
      const current = combatRef.current
      const nextElapsedTicks = current.elapsedTicks + 1
      const nextCooldowns = Object.fromEntries(
        Object.entries(current.cooldowns).map(([id, seconds]) => [
          id,
          Math.max(0, seconds - TICK_MS / 1000),
        ]),
      )
      let nextResource = clamp(current.resource + 2.4, 0, maxResource)

      const living = current.party.filter((member) => member.health > 0)
      if (living.length === 0) {
        if (isRoguelike) finishRoguelikeRun(encounterIndex)
        setStatus('lost')
        addLog('The party has fallen.', 'danger')
        return
      }

      const primaryTarget = living[Math.floor(Math.random() * living.length)]
      const mechanics = (encounter as RoguelikeEncounter).roguelikeMechanics ?? []
      const useDefaultBossMechanics = encounter.isBoss && mechanics.length === 0
      const bossPulse = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 7 === 0
        && (useDefaultBossMechanics || mechanics.includes('party-pulse'))
      const appliesDebuff = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 11 === 0
        && (useDefaultBossMechanics || mechanics.includes('searing-mark'))
      const appliesMaxHealthCut = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 13 === 0
        && mechanics.includes('max-health-cut')
      const appliesHealingReduction = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 9 === 0
        && mechanics.includes('healing-reduction')
      const tankBuster = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 8 === 0
        && mechanics.includes('tank-buster')
      const resourceDrain = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 10 === 0
        && mechanics.includes('resource-drain')
      const appliesPoison = encounter.isBoss && nextElapsedTicks > 0 && nextElapsedTicks % 12 === 0
        && mechanics.includes('ramping-poison')
      if (bossPulse) addLog(`${encounter.enemyName} unleashes party-wide damage.`, 'danger')
      if (appliesDebuff) addLog(`${primaryTarget.name} is afflicted by Searing Mark.`, 'danger')
      if (appliesPoison) addLog(`${primaryTarget.name} is poisoned. Dispel it before it ramps.`, 'danger')
      if (appliesMaxHealthCut) addLog(`${primaryTarget.name}'s max health is reduced.`, 'danger')
      if (appliesHealingReduction) addLog(`${primaryTarget.name} receives reduced healing.`, 'danger')
      if (tankBuster) addLog(`${encounter.enemyName} crushes the tanks.`, 'danger')
      if (resourceDrain) {
        nextResource = clamp(nextResource - 8, 0, maxResource)
        addLog(`${encounter.enemyName} drains ${gameClass.resourceName}.`, 'danger')
      }

      const healerBeforeDamage = current.party.find((member) => member.id === 'mira')
      const tankPressure = tankPressureTargets(current.party)
      const tankPressureIds = new Set(tankPressure.targets.map((member) => member.id))
      const pendingJumpHeals: Array<{
        targetId: string
        heal: NonNullable<PartyMember['bounceHeals']>[number]
      }> = []
      const damagedParty = current.party.map((member) => {
        if (member.health <= 0) return member
        let damage = member.id === primaryTarget.id ? encounter.damage : 0
        if (tankPressureIds.has(member.id)) {
          damage += Math.round(encounter.tankDamage * tankPressure.multiplier)
        }
        if (tankBuster && tankPressureIds.has(member.id)) {
          damage += Math.round(22 * difficulty.damageMultiplier * tankPressure.multiplier)
        }
        if (bossPulse) damage += Math.round(12 * difficulty.damageMultiplier)
        if (member.debuff) damage += Math.round(7 * difficulty.damageMultiplier)
        const nextPoisonStacks = appliesPoison && member.id === primaryTarget.id
          ? Math.max(1, (member.poisonStacks ?? 0) + 1)
          : member.poisonStacks ?? 0
        if (nextPoisonStacks > 0) damage += Math.round((4 + nextPoisonStacks * 4) * difficulty.damageMultiplier)
        damage *= enemyCount
        if ((member.damageReductionTicks ?? 0) > 0) {
          damage = Math.round(damage * 0.5)
        }
        if (member.shield > 0 && activeEffects.has('shielded_damage_reduction')) {
          damage = Math.round(damage * 0.8)
        }
        const absorbed = Math.min(member.shield, damage)
        const hotEffects = memberHotEffects(member)
        const healingMultiplier = member.shield > 0 && activeEffects.has('shielded_healing_bonus') ? 1.2 : 1
        let healing = hotEffects.reduce((total, effect) => total + healAmount(member, effect.power, healingMultiplier), 0)
        let nextBounceHeals = [...(member.bounceHeals ?? [])]
        if (damage > 0 && nextBounceHeals.length > 0) {
          nextBounceHeals = nextBounceHeals.flatMap((effect) => {
            healing += healAmount(member, effect.power, healingMultiplier)
            const nextCharges = effect.charges - 1
            if (nextCharges <= 0) return []
            const jumpTargets = current.party.filter((candidate) => candidate.health > 0 && candidate.id !== member.id)
            const jumpTarget = jumpTargets[Math.floor(Math.random() * jumpTargets.length)] ?? member
            pendingJumpHeals.push({
              targetId: jumpTarget.id,
              heal: { ...effect, charges: nextCharges },
            })
            return []
          })
        }
        if (healing > 0) addFloatingHeal(member.id, healing)
        const nextMaxHealthPenaltyTicks = appliesMaxHealthCut && member.id === primaryTarget.id
          ? 15
          : Math.max(0, (member.maxHealthPenaltyTicks ?? 0) - 1)
        const nextHealingReductionTicks = appliesHealingReduction && member.id === primaryTarget.id
          ? 15
          : Math.max(0, (member.healingReductionTicks ?? 0) - 1)
        const nextEffectiveMaxHealth = Math.max(1, Math.round(member.maxHealth * (nextMaxHealthPenaltyTicks > 0 ? 0.75 : 1)))
        const nextDebuffTicks = appliesDebuff && member.id === primaryTarget.id
          ? 8
          : Math.max(0, (member.debuffTicks ?? 0) - 1)
        return {
          ...member,
          health: clamp(clamp(member.health + healing, 0, nextEffectiveMaxHealth) - damage + absorbed, 0, nextEffectiveMaxHealth),
          shield: Math.max(0, member.shield - damage),
          hotTicks: 0,
          hotEffects: tickHotEffects(hotEffects),
          bounceHeals: nextBounceHeals,
          damageReductionTicks: Math.max(0, (member.damageReductionTicks ?? 0) - 1),
          debuff: nextDebuffTicks > 0
            ? (appliesDebuff && member.id === primaryTarget.id ? 'Searing Mark' : member.debuff)
            : undefined,
          debuffTicks: nextDebuffTicks > 0 ? nextDebuffTicks : undefined,
          poisonStacks: nextPoisonStacks,
          maxHealthPenaltyTicks: nextMaxHealthPenaltyTicks,
          healingReductionTicks: nextHealingReductionTicks,
        }
      })
      const nextParty = damagedParty.map((member) => {
        const jumped = pendingJumpHeals.filter((jump) => jump.targetId === member.id)
        if (jumped.length === 0) return member
        return {
          ...member,
          bounceHeals: [
            ...(member.bounceHeals ?? []),
            ...jumped.map((jump) => jump.heal),
          ],
        }
      })
      const healerAfterDamage = nextParty.find((member) => member.id === 'mira')

      if (
        healerBeforeDamage
        && healerBeforeDamage.health > 0
        && healerAfterDamage
        && healerAfterDamage.health <= 0
      ) {
        addLog(`${profile.character.name} has fallen. Healing is no longer available.`, 'danger')
      }

      if (nextParty.every((member) => member.health <= 0)) {
        setCombat({
          ...current,
          party: nextParty,
          resource: nextResource,
          cooldowns: nextCooldowns,
          elapsedTicks: nextElapsedTicks,
          enemyHealth: current.enemyHealth,
        })
        if (isRoguelike) finishRoguelikeRun(encounterIndex)
        setStatus('lost')
        addLog('The party has fallen.', 'danger')
        return
      }

      const nextEnemyHealth = current.enemyHealth - partyDamageOutput(nextParty, encounter.partyDamage)
      if (nextEnemyHealth > 0) {
        setCombat({
          ...current,
          party: nextParty,
          resource: nextResource,
          cooldowns: nextCooldowns,
          elapsedTicks: nextElapsedTicks,
          enemyHealth: nextEnemyHealth,
        })
        return
      }

      if (!isRoguelike && encounter.lootTables.some((entry) => entry.difficultyId === difficulty.id)) {
        for (let rollIndex = 0; rollIndex < enemyCount; rollIndex += 1) {
          requestLootRoll(encounter.id, rollIndex)
        }
      }

      if (isRoguelike && (upgradesEveryEncounter || encounter.isBoss)) {
        setCombat({
          ...current,
          party: nextParty,
          resource: nextResource,
          cooldowns: nextCooldowns,
          elapsedTicks: nextElapsedTicks,
          enemyHealth: 0,
        })
        setUpgradeChoices(chooseRandom(roguelikeUpgradeCatalog, 3))
        setStatus('upgrade-choice')
        addLog(`${encounter.enemyName} defeated. Choose an upgrade.`, 'loot')
        return
      }

      if (isPartBoss && !isFinalBoss) {
        setCombat({
          ...current,
          party: nextParty,
          resource: nextResource,
          cooldowns: nextCooldowns,
          elapsedTicks: nextElapsedTicks,
          enemyHealth: 0,
        })
        setStatus('part-complete')
        addLog(`${encounter.enemyName} is defeated.`, 'loot')
        return
      }

      if (encounterIndex === encounters.length - 1) {
        setCombat({
          ...current,
          party: nextParty,
          resource: nextResource,
          cooldowns: nextCooldowns,
          elapsedTicks: nextElapsedTicks,
          enemyHealth: 0,
        })
        finishRun(currentPart, startPart)
        addLog(`${encounter.enemyName} is defeated. Rolling its loot table.`, 'loot')
        return
      }

      const nextEncounter = encounters[encounterIndex + 1]
      const recoveredParty = nextParty.map((member) => ({
        ...member,
        health: member.health <= 0
          ? 0
          : clamp(member.health + 35, 0, effectiveMaxHealth(member)),
        debuff: undefined,
        debuffTicks: undefined,
        poisonStacks: undefined,
        maxHealthPenaltyTicks: undefined,
        healingReductionTicks: undefined,
        hotEffects: [],
        bounceHeals: [],
        damageReductionTicks: undefined,
      }))
      setEncounterIndex((value) => value + 1)
      setCombat({
        ...current,
        party: recoveredParty,
        resource: nextResource,
        cooldowns: nextCooldowns,
        elapsedTicks: 0,
        enemyHealth: nextEncounter.maxHealth * enemyCount,
      })
      addLog(`${encounter.enemyName} defeated. ${nextEncounter.enemyName} approaches.`, 'system')
  }, [
    activeEffects,
    addLog,
    addFloatingHeal,
    difficulty.damageMultiplier,
    enemyCount,
    encounter,
    encounterIndex,
    encounters,
    finishRun,
    finishRoguelikeRun,
    isPartBoss,
    isFinalBoss,
    isRoguelike,
    upgradesEveryEncounter,
    roguelikeUpgradeCatalog,
    roguelikeUpgrades,
    maxResource,
    gameClass.resourceName,
    requestLootRoll,
    profile.character.name,
    setCombat,
    startPart,
    currentPart,
  ])

  useEffect(() => {
    runCombatTickRef.current = runCombatTick
  }, [runCombatTick])

  useEffect(() => {
    if (status === 'playing' && !paused) {
      if (!combatClockActiveRef.current) {
        lastCombatTickAtRef.current = performance.now()
        combatClockActiveRef.current = true
      }
      return
    }
    combatClockActiveRef.current = false
  }, [paused, status])

  useEffect(() => {
    const timer = window.setInterval(() => {
      if (
        !combatClockActiveRef.current
        || statusRef.current !== 'playing'
        || pausedRef.current
      ) return
      const now = performance.now()
      const tickMs = TICK_MS / speedMultiplierRef.current
      const dueTicks = Math.min(8, Math.floor((now - lastCombatTickAtRef.current) / tickMs))
      if (dueTicks <= 0) return
      lastCombatTickAtRef.current += dueTicks * tickMs
      for (let index = 0; index < dueTicks; index += 1) {
        if (statusRef.current !== 'playing' || pausedRef.current) return
        runCombatTickRef.current()
      }
    }, 50)
    return () => window.clearInterval(timer)
  }, [])

  useEffect(() => {
    if (
      !reward
      || lootRolls.length < expectedLootRolls
      || profileRefreshedRef.current
    ) return
    profileRefreshedRef.current = true
    loadProfile()
      .then(onProfileUpdated)
      .catch(() => {
        profileRefreshedRef.current = false
      })
  }, [expectedLootRolls, lootRolls.length, onProfileUpdated, reward])

  const enemyPercent = (enemyHealth / encounterMaxHealth) * 100
  const enemyHealthSegments = Array.from({ length: enemyCount }, (_, index) => {
    const remaining = clamp(enemyHealth - encounter.maxHealth * index, 0, encounter.maxHealth)
    return {
      index,
      health: remaining,
      percent: (remaining / encounter.maxHealth) * 100,
    }
  }).reverse()
  const dualScreenState = useMemo<DualScreenCombatState>(() => ({
    difficultyName: difficulty.name,
    dungeonName: hardMode ? `${dungeon.name} Hard` : dungeon.name,
    contentName,
    encounterName: encounter.enemyName,
    encounterDescription: encounter.description,
    encounterHealth: enemyHealth,
    encounterMaxHealth,
    encounterIsBoss: encounter.isBoss,
    encounterIndex,
    encounterCount: encounters.length,
    party,
    partySize: dungeon.partySize,
    selectedId,
    log,
    status,
    resource,
    maxResource,
    resourceName: gameClass.resourceName,
    playerIsAlive,
    spells: profile.abilitySlots.map((abilityId, slotIndex) => {
      const spell = spells.find((candidate) => candidate.key === String(slotIndex + 1))
      return abilityId && spell
        ? {
          ...spell,
          cost: spellResourceCost(spell, roguelikeUpgrades, freeCastReady),
          slotIndex,
          remaining: cooldowns[spell.id] ?? 0,
        }
        : null
    }),
    activeDevice: lastDevice,
    bindings: bindings[lastDevice],
    controllerIconStyle,
    directPartyTargeting,
    paused,
    targetGroup,
    speedMultiplier,
  }), [
    bindings,
    controllerIconStyle,
    cooldowns,
    contentName,
    difficulty.name,
    dungeon.name,
    dungeon.partySize,
    directPartyTargeting,
    encounter.description,
    encounter.enemyName,
    encounter.isBoss,
    encounterMaxHealth,
    hardMode,
    enemyHealth,
    encounterIndex,
    encounters.length,
    gameClass.resourceName,
    log,
    lastDevice,
    maxResource,
    paused,
    party,
    playerIsAlive,
    profile.abilitySlots,
    resource,
    selectedId,
    spells,
    freeCastReady,
    roguelikeUpgrades,
    speedMultiplier,
    status,
    targetGroup,
  ])
  useDualScreenPublisher(dualScreenState, dualScreenEnabled)

  return (
    <main
      className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
      data-combat-active={paused ? 'false' : 'true'}
    >
      {!dualScreenEnabled && <header className="topbar">
        <div>
          <p className="eyebrow">{difficulty.name}{hardMode ? ' Hard' : ''} - Item Level {difficulty.droppedItemLevel}</p>
          <h1>{dungeon.name}</h1>
        </div>
        <div className="combat-header-actions">
          <div className="run-progress" aria-label={`${contentName} progress`}>
            {encounters.map((item, index) => (
              <span className={index < encounterIndex ? 'complete' : index === encounterIndex ? 'active' : ''} key={item.id}>
                {index + 1}
              </span>
            ))}
          </div>
          <button className="back-button" onClick={() => setPaused(true)} type="button">Pause</button>
        </div>
      </header>}

      {!dualScreenEnabled && (
        <>
          <section className="enemy-card">
            <div className="enemy-portrait" aria-hidden="true">
              {encounter.isBoss ? <img src={encounter.imageUrl} alt="" /> : 'M'}
            </div>
            <div className="enemy-info">
              <div className="bar-label">
                <strong>{hardMode ? `${encounter.enemyName} x2` : encounter.enemyName}</strong>
                <span>{Math.ceil(enemyHealth)} / {encounterMaxHealth}</span>
              </div>
              {hardMode ? (
                <div className="hard-enemy-bars">
                  {enemyHealthSegments.map((segment) => (
                    <div className="bar enemy-health" key={segment.index}>
                      <span style={{ width: `${segment.percent}%` }} />
                      <em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
                    </div>
                  ))}
                </div>
              ) : (
                <div className="bar enemy-health"><span style={{ width: `${enemyPercent}%` }} /></div>
              )}
              <p>{encounter.description}</p>
            </div>
          </section>

          <div className="combat-layout">
            <section className="party-panel">
              <div className="party-panel-top">
                <div className="mana-wrap party-mana-wrap">
                  <span>
                    {playerIsAlive
                      ? `${gameClass.resourceName} ${Math.floor(resource)} / ${maxResource}`
                      : `${profile.character.name} is defeated`}
                  </span>
                  {speedMultiplier === 2 && <strong className="speed-badge">2x speed</strong>}
                  <div className="bar mana-bar"><span style={{ width: `${(resource / maxResource) * 100}%` }} /></div>
                </div>
              </div>
              <div className={`party-grid ${dungeon.partySize >= 10 ? 'raid-party-grid' : ''}`}>
                {party.map((member) => (
                  <button
                    className={`party-member ${selectedId === member.id ? 'selected' : ''} ${member.health <= 0 ? 'dead' : ''}`}
                    data-party-member-id={member.id}
                    key={member.id}
                    onClick={() => setSelectedTargetId(member.id)}
                    aria-pressed={selectedId === member.id}
                    type="button"
                  >
                    <span className="target-marker" aria-hidden="true">
                      <i />
                      Target
                    </span>
                    <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}%` }} />}
                      <em className="health-text">{Math.ceil(member.health)} / {effectiveMaxHealth(member)}</em>
                    </div>
                    <div className="floating-combat-texts" aria-hidden="true">
                      {floatingTexts
                        .filter((entry) => entry.memberId === member.id)
                        .map((entry) => <span className="floating-heal" key={entry.id}>+{entry.value}</span>)}
                    </div>
                    <div className="member-effects">
                      {memberHotEffects(member).map((effect) => (
                        <span className="buff" key={effect.id}>{effect.label} {formatEffectTime(effect.ticks)}</span>
                      ))}
                      {member.shield > 0 && <span className="buff">Shield {Math.ceil(member.shield)}</span>}
                      {(member.damageReductionTicks ?? 0) > 0 && <span className="buff">Barkskin {formatEffectTime(member.damageReductionTicks ?? 0)}</span>}
                      {(member.bounceHeals ?? []).map((effect) => (
                        <span className="buff" key={effect.id}>{effect.label} {effect.charges}</span>
                      ))}
                      {member.debuff && member.debuffTicks && <span className="debuff">{member.debuff} {formatEffectTime(member.debuffTicks)}</span>}
                      {member.poisonStacks && member.poisonStacks > 0 && <span className="debuff">Poison {member.poisonStacks}</span>}
                      {member.maxHealthPenaltyTicks && member.maxHealthPenaltyTicks > 0 && <span className="debuff">Max HP -25% {formatEffectTime(member.maxHealthPenaltyTicks)}</span>}
                      {member.healingReductionTicks && member.healingReductionTicks > 0 && <span className="debuff">Healing -25% {formatEffectTime(member.healingReductionTicks)}</span>}
                    </div>
                  </button>
                ))}
              </div>
            </section>

            <aside className="combat-log combat-side-rail">
              <div className="panel-heading">
                <div><p className="eyebrow">Actions</p><h2>Skills</h2></div>
              </div>
              <div className="spell-bar six-slots vertical-spell-bar">
                {profile.abilitySlots.map((abilityId, slotIndex) => {
                  const spell = spells.find((candidate) => candidate.key === String(slotIndex + 1))
                  if (!abilityId || !spell) {
                    return (
                      <div className="spell empty-spell" key={`rail-empty-${slotIndex}`}>
                        <kbd>{slotIndex + 1}</kbd><strong>Empty</strong>
                      </div>
                    )
                  }
                  const remaining = cooldowns[spell.id] ?? 0
                  const effectiveCost = spellResourceCost(spell, roguelikeUpgrades, freeCastReady)
                  return (
                    <button
                      className="spell"
                      disabled={!playerIsAlive || resource < effectiveCost || remaining > 0 || status !== 'playing'}
                      key={`rail-${spell.id}`}
                      onClick={() => castSpell(spell)}
                      title={spell.description}
                      type="button"
                    >
                      <kbd>
                        <ControllerBindingLabel
                          binding={bindings[lastDevice][`ability${slotIndex + 1}` as InputAction]}
                          compact
                          iconStyle={controllerIconStyle}
                        />
                      </kbd>
                      <span className={`spell-icon spell-${spell.kind}`}>{spell.glyph}</span>
                      <strong>{spell.name}</strong>
                      <small>{effectiveCost} {gameClass.resourceName}</small>
                      {remaining > 0 && <i>{remaining.toFixed(1)}</i>}
                    </button>
                  )
                })}
              </div>
            </aside>
          </div>
        </>
      )}

      {dualScreenEnabled && (
        <DualScreenTopCombat
          state={dualScreenState}
          onSelectTarget={setSelectedTargetId}
        />
      )}

      {paused && status === 'playing' && (
        <div className="pause-screen" role="dialog" aria-modal="true">
          <div>
            <p className="eyebrow">Game Paused</p>
            <h2>{dungeon.name}</h2>
            <p>Combat is stopped. Resume the fight or leave the current run.</p>
            <button onClick={() => setPaused(false)} type="button">Resume</button>
            <button className="secondary-result-button" onClick={onExit} type="button">
              Leave {contentName}
            </button>
          </div>
        </div>
      )}

      {status === 'upgrade-choice' && (
        <div className="result-screen">
          <div className="pvp-upgrade-dialog pve-upgrade-dialog">
            <p className="eyebrow">
              {encounter.isBoss
                ? `Roguelike Stage ${roguelikeStage} Complete`
                : `Encounter ${encounterIndex + 1} Complete`}
            </p>
            <h2>Choose Upgrade</h2>
            <p>Pick one upgrade before the next fight.</p>
            <div className="pvp-choice-columns">
              <div>
                <strong>Run Buff</strong>
                <div className="upgrade-choice-grid">
                  {upgradeChoices.map((upgrade) => (
                    <button key={upgrade.id} onClick={() => chooseRoguelikeUpgrade(upgrade)} type="button">
                      <strong>{upgrade.name}</strong>
                      <small>{upgrade.description}</small>
                    </button>
                  ))}
                </div>
              </div>
            </div>
            {roguelikeUpgrades.length > 0 && (
              <p className="roguelike-upgrade-list">
                Active: {summarizeUpgradeStacks(roguelikeUpgrades, roguelikeUpgradeCatalog)}
              </p>
            )}
            <button className="secondary-result-button" onClick={onExit} type="button">Leave Roguelike</button>
          </div>
        </div>
      )}

      {status !== 'playing' && status !== 'part-complete' && status !== 'upgrade-choice' && (
        <div className="result-screen">
          <div>
            <p className="eyebrow">{status === 'won' ? `${contentName} Complete` : 'Party Defeated'}</p>
            <h2>{status === 'won' ? 'The Warden Falls' : 'The Ashes Claim You'}</h2>
            {status === 'won' ? (
              <div className="reward-summary">
                {!reward && !rewardError && <p>Recording victory...</p>}
                {rewardError && <p className="reward-error">{rewardError}</p>}
                {reward && (
                  <>
                    <p>+{reward.experienceGained} XP</p>
                    {reward.levelsGained > 0 && (
                      <p className="level-gain">
                        Level {reward.previousLevel} to {reward.newLevel}
                        <small>+{reward.talentPointsGained} talent point</small>
                      </p>
                    )}
                    {reward.unlockedAbilities.map((ability) => (
                      <p className="ability-unlock" key={ability.id}>
                        <span>{ability.glyph}</span>
                        Ability Unlocked: {ability.name}
                      </p>
                    ))}
                    {!isRoguelike && (
                      <>
                        <p>Component tier: item level {reward.droppedItemLevel}.</p>
                        <p className="efficiency-result">
                          {reward.resourceSpent} {gameClass.resourceName} spent
                          <small>
                            {reward.durationSeconds}s - iLvl {reward.averageItemLevel.toFixed(1)}
                          </small>
                        </p>
                        <div className="run-loot-rolls">
                          {lootRolls.map((roll) => (
                            <div className={roll.dropped ? 'dropped' : 'empty'} key={roll.encounterId}>
                              <strong>{roll.encounterName}</strong>
                              <span>
                                {roll.items.length > 0
                                  ? roll.items
                                    .map((item) => `${item.glyph} ${item.name} x${item.quantity}${item.duplicate ? ` (owned x${item.quantityAfter})` : ''}`)
                                    .join(', ')
                                  : 'No components dropped'}
                              </span>
                            </div>
                          ))}
                          {lootRolls.length < expectedLootRolls && (
                            <small>Finishing loot rolls...</small>
                          )}
                        </div>
                        {reward.bonusItem && (
                          <div className="bonus-item">
                            <p className="eyebrow">Full Run Bonus</p>
                            <div className="bonus-item-detail">
                              <span>{reward.bonusItem.glyph}</span>
                              <strong className={`rarity-${reward.bonusItem.rarity}`}>{reward.bonusItem.name}</strong>
                              <small>Item Level {reward.bonusItem.itemLevel} x{reward.bonusItem.quantity}</small>
                              {reward.bonusItem.duplicate && <small> (owned x{reward.bonusItem.quantityAfter})</small>}
                            </div>
                          </div>
                        )}
                      </>
                    )}
                  </>
                )}
              </div>
            ) : isRoguelike ? (
              <div className="reward-summary">
                {!reward && !rewardError && <p>Recording roguelike progress...</p>}
                {rewardError && <p className="reward-error">{rewardError}</p>}
                {reward && (
                  <>
                    <p>+{reward.experienceGained} XP</p>
                    <p>{encounterIndex} encounters cleared.</p>
                    {reward.levelsGained > 0 && (
                      <p className="level-gain">
                        Level {reward.previousLevel} to {reward.newLevel}
                        <small>+{reward.talentPointsGained} talent point</small>
                      </p>
                    )}
                    {reward.unlockedAbilities.map((ability) => (
                      <p className="ability-unlock" key={ability.id}>
                        <span>{ability.glyph}</span>
                        Ability Unlocked: {ability.name}
                      </p>
                    ))}
                    <p className="efficiency-result">
                      {reward.resourceSpent} {gameClass.resourceName} spent
                      <small>{reward.durationSeconds}s survived</small>
                    </p>
                  </>
                )}
              </div>
            ) : (
              <p>Balance efficient healing, shields, and cleansing to survive.</p>
            )}
            {log.length > 0 && (
              <>
                <button className="secondary-result-button" onClick={() => setShowEndLog((value) => !value)} type="button">
                  {showEndLog ? 'Hide Combat Log' : 'View Combat Log'}
                </button>
                {showEndLog && (
                  <div className="result-log">
                    {log.slice().reverse().map((entry) => (
                      <div className={`log-entry ${entry.tone}`} key={entry.id}>{entry.text}</div>
                    ))}
                  </div>
                )}
              </>
            )}
            <button onClick={resetRun} type="button">Run Again</button>
            <button className="secondary-result-button" onClick={onExit} type="button">Leave {contentName}</button>
          </div>
        </div>
      )}
      {status === 'part-complete' && (
        <div className="result-screen">
          <div>
            <p className="eyebrow">{sectionName} Complete</p>
            <h2>{encounter.enemyName} Defeated</h2>
            <p>{canContinueAfterPart ? `Proceed to ${sectionName} ${currentPart + 1} or end the run?` : 'Hard mode for this section is complete.'}</p>
            {canContinueAfterPart && (
              <button
                onClick={() => {
                  const nextIndex = encounterIndex + 1
                  partStartTimesRef.current[currentPart + 1] = Date.now()
                  const nextEncounter = encounters[nextIndex]
                  const current = combatRef.current
                  const recoveredParty = current.party.map((member) => ({
                    ...member,
                    health: clamp(member.health + 35, 0, member.maxHealth),
                    debuff: undefined,
                    debuffTicks: undefined,
                    poisonStacks: undefined,
                    maxHealthPenaltyTicks: undefined,
                    healingReductionTicks: undefined,
                    hotEffects: [],
                    bounceHeals: [],
                    damageReductionTicks: undefined,
                  }))
                  setEncounterIndex(nextIndex)
                  setCombat({
                    ...current,
                    party: recoveredParty,
                    enemyHealth: nextEncounter.maxHealth * enemyCount,
                    elapsedTicks: 0,
                  })
                  setStatus('playing')
                  addLog(`Proceeding to ${sectionName} ${currentPart + 1}. ${nextEncounter.enemyName} approaches.`, 'system')
                }}
                type="button"
              >
                Continue to {sectionName} {currentPart + 1}
              </button>
            )}
            <button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
              End Run
            </button>
          </div>
        </div>
      )}
    </main>
  )
}
