import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
  completeDungeon,
  completeRoguelike,
  loadProfile,
  recordBossKill,
  type DungeonReward,
  rollEncounterLoot,
  type LootRoll,
} from '../profile'
import {
  INITIAL_PARTY,
  RAID_PARTY,
  DEFAULT_GROUP_HEAL_TARGETS,
  partyDamageOutput,
  tankPressureTargets,
  type CombatLogEntry,
  type PartyMember,
  type Spell,
} from '../game'
import type {
  CharacterProfile,
  Difficulty,
  Dungeon,
  DungeonEncounter,
} from '../profile'
import {
  chooseRandom,
  clamp,
  effectiveMaxHealth,
  healMember,
  toCombatSpell,
} from '../combat/rules'
import {
  nextTargetGroupSelection,
} from '../combat/targeting'
import { buildRoguelikeSegment } from '../combat/encounters'
import {
  buildSelfSlotUpgradeChoices,
  summarizeChoiceStacks,
} from '../combat/roguelikeUpgrades'
import { createStackCounts, type StackCounts } from '../combat/stackCounts'
import {
  hasSpellStack,
  spellCooldownMultiplier,
  spellExtraTargets,
  spellPowerMultiplier,
  spellResourceCost as modifiedSpellResourceCost,
} from '../combat/spellModifiers'
import { regenerateResource } from '../combat/combatTick'
import { advanceMemberTick, attachJumpedBounceHeals } from '../combat/combatEngine'
import { applyCastStateUpdate } from '../combat/combatStateTransitions'
import { buildCombatDualScreenState } from '../combat/dualScreenPayloads'
import {
  createPveCombatState,
  createPveRoguelikeRunStart,
} from '../combat/pveRoguelikeRunSetup'
import {
  appendCombatLog,
  type BasicFloatingCombatText,
} from '../combat/combatPresentation'
import {
  canCastSpell,
  pruneExpiredCooldowns,
  reduceCooldown,
} from '../combat/spellCasting'
import {
  applySpellEffectProfile,
  buildSpellTargetPlan,
  type SpellEffectProfile,
} from '../combat/spellEffects'
import {
  useGameAction,
  useInput,
} from '../input'
import { useFloatingCombatText } from '../hooks/useFloatingCombatText'
import { useSpellSlots } from '../hooks/useSpellSlots'
import { usePartyTargeting } from '../hooks/usePartyTargeting'
import { PartyMemberFrame } from './PartyFrames'
import { ResourceBar, SpellBar } from './SpellBars'
import { barFillStyle } from './barStyles'
import { BonusItemReward, LootRollList, RewardXpSummary } from './RewardPanels'
import { ResultScreen } from './ResultScreen'
import {
  DualScreenTopCombat,
  useDualScreen,
  useDualScreenPublisher,
} 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 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 buildRoguelikeUpgrades(
  spells: Spell[],
  labelMode: RoguelikeAbilityLabelMode,
): RoguelikeUpgrade[] {
  const slotUpgrades = buildSelfSlotUpgradeChoices<RoguelikeUpgradeId>({
    slots: ['1', '2', '3', '4', '5', '6'] as SlotKey[],
    spells,
    labelMode,
  })
  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[],
) {
  return summarizeChoiceStacks(
    upgrades.map((upgrade) => upgrade.id),
    catalog,
  )
}

function cooldownMultiplier(spell: Spell, upgrades: StackCounts<RoguelikeUpgradeId>) {
  return spellCooldownMultiplier(spell, {
    stacks: upgrades,
    id: (slot) => `slot${slot as SlotKey}-cooldown-down` as RoguelikeUpgradeId,
  })
}

function spellResourceCost(spell: Spell, upgrades: StackCounts<RoguelikeUpgradeId>, freeCastReady: boolean) {
  return modifiedSpellResourceCost({
    spell,
    costDown: {
      stacks: upgrades,
      id: (slot) => `slot${slot as SlotKey}-cost-down` as RoguelikeUpgradeId,
    },
    freeCastReady,
    freeCast: {
      stacks: upgrades,
      id: 'fifth-cast-free',
    },
  })
}

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

function chooseOtherLivingMember(living: PartyMember[], member: PartyMember) {
  if (living.length <= 1) return member
  let remaining = Math.floor(Math.random() * (living.length - 1))
  for (const candidate of living) {
    if (candidate.id === member.id) continue
    if (remaining === 0) return candidate
    remaining -= 1
  }
  return member
}

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 mechanics = chooseRandom(ROGUELIKE_MECHANICS, Math.min(2 + Math.floor(stage / 3), 4))
  return buildRoguelikeSegment<RoguelikeMechanic, { roguelikeMechanics: RoguelikeMechanic[] }>({
    pool,
    stage,
    mechanics,
    trashCandidateCount: (trashCount) => Math.min(trashCount, 4 + stage * (mode === 'raid' ? 1 : 2)),
    bossCandidateCount: (bossCount) => Math.min(bossCount, 2 + Math.floor((stage + 1) / 2)),
    healthScale: difficulty.healthMultiplier * (0.64 + stage * (mode === 'raid' ? 0.15 : 0.11)),
    damageScale: difficulty.damageMultiplier * (0.58 + stage * (mode === 'raid' ? 0.13 : 0.1)),
    partyDamageScale: 0.9 + stage * 0.05,
    idBase: 900000,
    bossDescription: (selectedMechanics) => `Roguelike boss with ${selectedMechanics.map(mechanicLabel).join(', ')}.`,
    extraFields: (_encounter, isBoss, selectedMechanics) => ({
      roguelikeMechanics: isBoss ? selectedMechanics : [],
    }),
  }) as RoguelikeEncounter[]
}

export function CombatScreen({
  difficulty,
  dungeon,
  hardMode = false,
  marathonMode = false,
  profile,
  startPart = 1,
  roguelikeMode,
  roguelikeUpgradeTiming = 'boss',
  roguelikeAbilityLabelMode = 'ability',
  roguelikeEncounterPool,
  onExit,
  onProfileUpdated,
}: {
  difficulty: Difficulty
  dungeon: Dungeon
  hardMode?: boolean
  marathonMode?: 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 = useMemo(() => {
    const abilityById = new Map(gameClass.spells.map((ability) => [ability.id, ability]))
    return profile.abilitySlots.flatMap((abilityId, index) => {
      const ability = abilityId === null ? undefined : abilityById.get(abilityId)
      return ability
        ? [toCombatSpell(ability, String(index + 1), healingPower)]
        : []
    })
  }, [gameClass.spells, healingPower, profile.abilitySlots])
  const spellByKey = useMemo(
    () => new Map(spells.map((spell) => [spell.key, spell])),
    [spells],
  )
  const spellByName = useMemo(
    () => new Map(spells.map((spell) => [spell.name, spell])),
    [spells],
  )
  const effectSpellByName = useMemo(
    () => new Map(gameClass.spells.map((ability) => [
      ability.name,
      toCombatSpell(ability, `effect-${ability.id}`, healingPower),
    ])),
    [gameClass.spells, 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' : 'Run'
  const contentName = isRoguelike ? 'Roguelike' : dungeon.contentType === 'raid' ? 'Raid' : 'Dungeon'
  const initialEncounterIndex = (startPart - 1) * 3
  const enemyCount = hardMode ? 2 : 1
  const initialCombatState = useMemo<SinglePlayerCombatState>(() => createPveCombatState({
    partyTemplate,
    maxResource,
    encounter: encounters[initialEncounterIndex],
    enemyCount,
  }), [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' | 'marathon-choice' | '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,
    floatingTextsByMember,
    addFloatingText,
    clearFloatingTexts,
  } = useFloatingCombatText<BasicFloatingCombatText>()
  const [roguelikeUpgrades, setRoguelikeUpgrades] = useState<RoguelikeUpgrade[]>([])
  const [upgradeChoices, setUpgradeChoices] = useState<RoguelikeUpgrade[]>([])
  const [marathonBossesDefeated, setMarathonBossesDefeated] = useState(0)
  const rewardClaimedRef = useRef(false)
  const profileRefreshedRef = useRef(false)
  const rolledEncounterIdsRef = useRef(new Set<string>())
  const recordedBossKillIdsRef = 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 marathonBossesDefeatedRef = useRef(0)
  const combatRef = useRef(initialCombatState)
  const selectedIdRef = useRef(partyTemplate[0].id)
  const runCombatTickRef = useRef<() => void>(() => {})
  const combatClockActiveRef = useRef(false)
  const lastCombatTickAtRef = useRef(0)
  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 = useMemo(
    () => encounters
      .slice(firstEncounterIndex, encounterIndex + 1)
      .filter((candidate) => candidate.lootTables.some((entry) => entry.difficultyId === difficulty.id))
      .length * enemyCount,
    [difficulty.id, encounters, encounterIndex, enemyCount, firstEncounterIndex],
  )
  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 roguelikeUpgradeCounts = useMemo(
    () => createStackCounts(roguelikeUpgrades.map((upgrade) => upgrade.id)),
    [roguelikeUpgrades],
  )
  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()
  const activeBindings = bindings[lastDevice]

  useEffect(() => {
    lastCombatTickAtRef.current = performance.now()
  }, [])

  useEffect(() => {
    statusRef.current = status
    pausedRef.current = paused
    speedMultiplierRef.current = speedMultiplier
  }, [paused, speedMultiplier, status])

  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) => appendCombatLog(current, entry))
  }, [])

  const addFloatingHeal = useCallback((memberId: string, value: number) => {
    addFloatingText({ memberId, value })
  }, [addFloatingText])

  const requestLootRoll = useCallback(
    (encounterId: number, rollIndex = 0) => {
      const rollKey = `${encounterId}:${rollIndex}:${marathonBossesDefeatedRef.current}`
      if (rolledEncounterIdsRef.current.has(rollKey)) return
      rolledEncounterIdsRef.current.add(rollKey)
      const runToken = `${runTokenRef.current}-${marathonBossesDefeatedRef.current}-${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',
          )
          if (result.petAwarded) {
            addLog(
              `${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
              'loot',
            )
          }
        })
        .catch((reason: unknown) => {
          addLog(
            reason instanceof Error ? reason.message : 'The loot roll failed.',
            'danger',
          )
        })
    },
    [addLog, difficulty.id],
  )

  const recordRoguelikeBossKill = useCallback((encounter: DungeonEncounter) => {
    if (!isRoguelike || !encounter.isBoss) return
    const key = `${runTokenRef.current}:${encounter.id}:${encounterIndex}`
    if (recordedBossKillIdsRef.current.has(key)) return
    recordedBossKillIdsRef.current.add(key)
    recordBossKill(encounter.id, { petVariant: 'purple' })
      .then((result) => {
        onProfileUpdated(result.profile)
        if (result.petAwarded) {
          addLog(
            `${result.petAwarded.petName} awarded${result.petAwarded.duplicate ? ` (owned x${result.petAwarded.quantityAfter})` : ''}.`,
            'loot',
          )
        }
      })
      .catch((reason: unknown) => {
        addLog(
          reason instanceof Error ? reason.message : 'Unable to record boss kill.',
          'danger',
        )
      })
  }, [addLog, encounterIndex, isRoguelike, onProfileUpdated])

  const resetRun = useCallback(() => {
    const nextRoguelikeEncounters = roguelikeMode
      ? makeRoguelikeSegment(roguelikePool, 1, difficulty, roguelikeMode)
      : []
    const nextEncounters = roguelikeMode ? nextRoguelikeEncounters : staticEncounters
    const setup = createPveRoguelikeRunStart({
      partyTemplate,
      maxResource,
      encounters: nextEncounters,
      initialEncounterIndex,
      enemyCount,
    })
    setCombat(setup.combatState)
    if (roguelikeMode) setRoguelikeEncounters(nextRoguelikeEncounters)
    setRoguelikeStage(setup.defaults.roguelikeStage)
    setSelectedTargetId(setup.defaults.selectedId)
    setEncounterIndex(setup.defaults.encounterIndex)
    setStatus(setup.defaults.status)
    setPaused(setup.defaults.paused)
    setTargetGroup(setup.defaults.targetGroup)
    setReward(setup.defaults.reward)
    setRewardError(setup.defaults.rewardError)
    setLootRolls(setup.defaults.lootRolls)
    setShowEndLog(setup.defaults.showEndLog)
    clearFloatingTexts()
    setRoguelikeUpgrades(setup.defaults.roguelikeUpgrades)
    setUpgradeChoices(setup.defaults.upgradeChoices)
    setMarathonBossesDefeated(setup.defaults.marathonBossesDefeated)
    rewardClaimedRef.current = false
    profileRefreshedRef.current = false
    rolledEncounterIdsRef.current = new Set()
    recordedBossKillIdsRef.current = new Set()
    runTokenRef.current = crypto.randomUUID()
    marathonBossesDefeatedRef.current = 0
    resourceSpentRef.current = setup.defaults.resourceSpent
    runStartedAtRef.current = Date.now()
    partStartTimesRef.current = { [startPart]: runStartedAtRef.current }
    setLog([{ id: nextLogId.current++, ...setup.defaults.log }])
  }, [clearFloatingTexts, difficulty, enemyCount, initialEncounterIndex, maxResource, partyTemplate, roguelikeMode, roguelikePool, setCombat, setSelectedTargetId, startPart, staticEncounters])

  const castSpell = useCallback(
    (spell: Spell) => {
      const current = combatRef.current
      const effectiveCost = spellResourceCost(spell, roguelikeUpgradeCounts, current.freeCastReady)
      if (status !== 'playing' || !canCastSpell(spell, current.resource, current.cooldowns, 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 renewEffect = effectSpellByName.get('Renew') ?? null
      const shieldEffect = effectSpellByName.get('Sun Ward') ?? null
      const healingMultiplier = (member: PartyMember) =>
        activeEffects.has('shielded_healing_bonus') && member.shield > 0 ? 1.2 : 1
      const extraTargets = spellExtraTargets(spell, {
        stacks: roguelikeUpgradeCounts,
        id: (slot) => `slot${slot as SlotKey}-extra-target` as RoguelikeUpgradeId,
      })
      const {
        directTargets,
        hotTargets,
        shieldTargets,
        groupTargets,
      } = buildSpellTargetPlan({
        party: current.party,
        spell,
        targetId,
        extraTargets,
        directTarget: true,
        hotTarget: spell.kind === 'hot' || spell.effectType === 'direct_hot',
        shieldTarget: spell.kind === 'shield',
        groupTargetCount: DEFAULT_GROUP_HEAL_TARGETS + extraTargets,
        extraTargetMode: {
          hot: 'hot',
          shield: 'shield',
        },
        beforeExtraTargetBuckets: [
          ...(spell.name === 'Mend' && activeEffects.has('mend_extra_target') ? ['direct' as const] : []),
          ...(spell.name === 'Renew' && activeEffects.has('renew_extra_target') ? ['hot' as const] : []),
        ],
      })
      if (spell.name === 'Mend' && activeEffects.has('mend_applies_renew')) {
        hotTargets.add(targetId)
      }
      if (spell.name === 'Mend' && activeEffects.has('mend_applies_shield') && shieldEffect) {
        directTargets.forEach((id) => shieldTargets.add(id))
      }

      const shieldBoost = spellPowerMultiplier({ stacks: roguelikeUpgradeCounts, id: 'shield-boost' })
      const groupHealBoost = spellPowerMultiplier({ stacks: roguelikeUpgradeCounts, id: 'group-heal-boost' })
      const spellEffectProfile: SpellEffectProfile = {
        modeName: 'pve',
        heal: healMember,
        healingMultiplier,
        power: {
          direct: (source) => source.power,
          cleanse: (source) => source.power,
          groupHeal: (source) => Math.round(source.power * groupHealBoost),
          groupAbsorb: (source) => Math.round(source.power * shieldBoost),
          shield: (sourcePower, strength = 1) => Math.round(sourcePower * strength * shieldBoost),
        },
        hot: {
          mode: 'effects',
          defaultTicks: 5,
          groupTicks: 5,
          radianceTicks: 3,
          merge: 'replace',
        },
        effects: {
          renewSpell: renewEffect,
          shieldSpell: shieldEffect,
          groupAbsorbOnly: (source) => source.effectType === 'party_absorb',
          groupHotOnly: (source) => source.effectType === 'party_hot',
          groupAppliesShield: (source) => source.effectType === 'party_absorb'
            || (source.name === 'Radiance' && activeEffects.has('radiance_applies_shield')),
          groupAppliesHot: (source) => source.name === 'Radiance' && activeEffects.has('radiance_applies_renew'),
          shieldAppliesHot: () => activeEffects.has('shield_applies_renew') && Boolean(renewEffect),
          hotSpellForDirect: (source) => source.name === 'Mend' && activeEffects.has('mend_applies_renew') && renewEffect
            ? renewEffect
            : source,
        },
        ratios: {
          groupShield: 0.3,
          directShield: 0.5,
        },
        damageReductionTicks: 12,
        floatingHeals: {
          group: true,
          direct: true,
          cleanse: false,
        },
        bounceHeals: true,
      }
      const { party: nextParty, floatingHeals } = applySpellEffectProfile({
        party: current.party,
        spell,
        targetId,
        plan: {
          directTargets,
          hotTargets,
          shieldTargets,
          damageReductionTargets: new Set(),
          groupTargets,
        },
        profile: spellEffectProfile,
      })
      floatingHeals.forEach((event) => addFloatingHeal(event.memberId, event.value))
      resourceSpentRef.current += effectiveCost
      let nextCooldowns = current.cooldowns
      if (spell.name === 'Mend' && activeEffects.has('mend_reduces_radiance_cooldown')) {
        const radiance = spellByName.get('Radiance')
        if (radiance) nextCooldowns = reduceCooldown(nextCooldowns, radiance.id, 2)
      }
      setCombat(applyCastStateUpdate({
        current,
        party: nextParty,
        cooldowns: nextCooldowns,
        spell,
        resourceCost: effectiveCost,
        cooldownMultiplier: cooldownMultiplier(spell, roguelikeUpgradeCounts),
        freeCast: {
          enabled: hasSpellStack({ stacks: roguelikeUpgradeCounts, id: 'fifth-cast-free' }),
          wasReady: current.freeCastReady,
        },
      }))
      addLog(`${spell.name} cast on ${spell.kind === 'group' ? 'the party' : selected.name}${effectiveCost === 0 ? ' for free' : ''}.`, 'heal')
    },
    [activeEffects, addFloatingHeal, addLog, effectSpellByName, roguelikeUpgradeCounts, setCombat, spellByName, 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 getTargetParty = useCallback(() => combatRef.current.party, [])
  const {
    selectRelativeTarget,
    selectDirectionalTarget,
    selectDirectTarget,
  } = usePartyTargeting({
    getParty: getTargetParty,
    selectedIdRef,
    setSelectedTargetId,
    columns: dungeon.partySize >= 10 ? 6 : 3,
    relativeLivingOnly: true,
    directionalLivingOnly: false,
    directLivingOnly: false,
    directTargetGroup: dungeon.partySize > 6 ? targetGroup : 0,
  })

  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 next = nextTargetGroupSelection(combatRef.current.party, selectedIdRef.current, current)
        if (next.selectedId) setSelectedTargetId(next.selectedId)
        return next.group as 0 | 1 | 2
      })
      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 = spellByKey.get(String(slot + 1))
    if (spell) castSpell(spell)
  })

  const runCombatTick = useCallback(() => {
      const current = combatRef.current
      const nextElapsedTicks = current.elapsedTicks + 1
      const nextCooldowns = pruneExpiredCooldowns(current.cooldowns)
      let nextResource = regenerateResource(current.resource, 2.4, 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 hasShieldedHealingBonus = activeEffects.has('shielded_healing_bonus')
      const shieldedDamageMultiplier = activeEffects.has('shielded_damage_reduction') ? 0.8 : undefined
      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)
        damage *= enemyCount
        const healingMultiplier = member.shield > 0 && hasShieldedHealingBonus ? 1.2 : 1
        const hasBounceHeals = Boolean(member.bounceHeals?.length)
        const result = advanceMemberTick({
          member,
          party: current.party,
          damage,
          hotHealing: 0,
          hotTicks: 'effects',
          healingMultiplier,
          shieldedDamageMultiplier,
          applyDebuff: appliesDebuff && member.id === primaryTarget.id
            ? { label: 'Searing Mark', ticks: 8 }
            : undefined,
          applyPoisonStacks: appliesPoison && member.id === primaryTarget.id,
          poisonDamage: (stacks) => Math.round((4 + stacks * 4) * difficulty.damageMultiplier),
          applyMaxHealthPenaltyTicks: appliesMaxHealthCut && member.id === primaryTarget.id ? 15 : undefined,
          applyHealingReductionTicks: appliesHealingReduction && member.id === primaryTarget.id ? 15 : undefined,
          decrementDamageReduction: true,
          useBounceHeals: hasBounceHeals,
          jumpTarget: hasBounceHeals ? () => chooseOtherLivingMember(living, member) : undefined,
        })
        if (result.floatingHeal > 0) addFloatingHeal(member.id, result.floatingHeal)
        pendingJumpHeals.push(...result.jumpedBounceHeals)
        return result.member
      })
      const nextParty = attachJumpedBounceHeals(damagedParty, pendingJumpHeals)
      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)
        }
      }
      recordRoguelikeBossKill(encounter)

      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) {
        const nextMarathonKills = marathonBossesDefeatedRef.current + 1
        marathonBossesDefeatedRef.current = nextMarathonKills
        setMarathonBossesDefeated(nextMarathonKills)
        setCombat({
          ...current,
          party: nextParty,
          resource: nextResource,
          cooldowns: nextCooldowns,
          elapsedTicks: nextElapsedTicks,
          enemyHealth: 0,
        })
        setStatus(marathonMode && encounter.isBoss ? 'marathon-choice' : 'part-complete')
        addLog(`${encounter.enemyName} is defeated.`, 'loot')
        return
      }

      if (encounterIndex === encounters.length - 1) {
        if (marathonMode && encounter.isBoss) {
          const nextMarathonKills = marathonBossesDefeatedRef.current + 1
          marathonBossesDefeatedRef.current = nextMarathonKills
          setMarathonBossesDefeated(nextMarathonKills)
          setCombat({
            ...current,
            party: nextParty,
            resource: nextResource,
            cooldowns: nextCooldowns,
            elapsedTicks: nextElapsedTicks,
            enemyHealth: 0,
          })
          setStatus('marathon-choice')
          addLog(`${encounter.enemyName} is defeated. Continue marathon or end the hunt.`, 'loot')
          return
        }
        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,
    difficulty.id,
    isPartBoss,
    isFinalBoss,
    isRoguelike,
    marathonMode,
    upgradesEveryEncounter,
    roguelikeUpgradeCatalog,
    maxResource,
    gameClass.resourceName,
    requestLootRoll,
    recordRoguelikeBossKill,
    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 = useMemo(
    () => 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(),
    [enemyCount, enemyHealth, encounter.maxHealth],
  )
  const spellSlotCost = useCallback(
    (spell: Spell) => spellResourceCost(spell, roguelikeUpgradeCounts, freeCastReady),
    [freeCastReady, roguelikeUpgradeCounts],
  )
  const spellSlots = useSpellSlots({
    spells,
    abilitySlots: profile.abilitySlots,
    cooldowns,
    active: status === 'playing' && !paused,
    cost: spellSlotCost,
  })
  const dualScreenState = useMemo(() => buildCombatDualScreenState({
    difficultyName: difficulty.name,
    dungeonName: dungeon.name,
    contentName,
    encounterName: encounter.enemyName,
    encounterDescription: encounter.description,
    encounterHealth: enemyHealth,
    encounterMaxHealth,
    encounterIsBoss: encounter.isBoss,
    encounterIndex,
    encounterCount: encounters.length,
    party,
    floatingTexts,
    partySize: dungeon.partySize,
    selectedId,
    status,
    resource,
    maxResource,
    resourceName: gameClass.resourceName,
    playerIsAlive,
    spells: spellSlots,
    bindings: activeBindings,
    controllerIconStyle,
    directPartyTargeting,
    paused,
    targetGroup,
    speedMultiplier,
  }), [
    activeBindings,
    controllerIconStyle,
    contentName,
    difficulty.name,
    dungeon.name,
    dungeon.partySize,
    directPartyTargeting,
    encounter.description,
    encounter.enemyName,
    encounter.isBoss,
    encounterMaxHealth,
    enemyHealth,
    encounterIndex,
    encounters.length,
    gameClass.resourceName,
    maxResource,
    paused,
    party,
    playerIsAlive,
    resource,
    selectedId,
    spellSlots,
    floatingTexts,
    speedMultiplier,
    status,
    targetGroup,
  ])
  useDualScreenPublisher(dualScreenState, dualScreenEnabled)

  return (
      <main
        className={`game-shell ${dualScreenEnabled ? 'dual-top-game-shell' : ''}`}
      data-combat-active={status === 'playing' && !paused ? 'true' : 'false'}
    >
      {!dualScreenEnabled && <header className="topbar">
        <div>
          <p className="eyebrow">{difficulty.name} - 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>{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={barFillStyle(segment.percent)} />
                      <em>{encounter.enemyName} {segment.index + 1}: {Math.ceil(segment.health)} / {encounter.maxHealth}</em>
                    </div>
                  ))}
                </div>
              ) : (
                <div className="bar enemy-health"><span style={barFillStyle(enemyPercent)} /></div>
              )}
              <p>{encounter.description}</p>
            </div>
          </section>

          <div className="combat-layout">
            <section className="party-panel">
                <div className="party-panel-top">
                <ResourceBar
                  maxResource={maxResource}
                  resource={resource}
                  resourceName={gameClass.resourceName}
                  speedMultiplier={speedMultiplier}
                  unavailableText={playerIsAlive ? undefined : `${profile.character.name} is defeated`}
                />
              </div>
              <div className={`party-grid ${dungeon.partySize >= 10 ? 'raid-party-grid' : ''}`}>
                {party.map((member) => (
                  <PartyMemberFrame
                    key={member.id}
                    member={member}
                    selected={selectedId === member.id}
                    floatingTexts={floatingTextsByMember.get(member.id) ?? []}
                    showTargetMarker
                    onSelect={setSelectedTargetId}
                  />
                ))}
              </div>
            </section>

            <aside className="combat-log combat-side-rail">
              <div className="panel-heading">
                <div><p className="eyebrow">Actions</p><h2>Skills</h2></div>
              </div>
              <SpellBar
                bindings={activeBindings}
                canCast={playerIsAlive && status === 'playing'}
                iconStyle={controllerIconStyle}
                onCast={castSpell}
                resource={resource}
                resourceName={gameClass.resourceName}
                spells={spellSlots}
              />
            </aside>
          </div>
        </>
      )}

      {dualScreenEnabled && (
        <DualScreenTopCombat
          state={dualScreenState}
          onCastSpell={castSpell}
          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 !== 'marathon-choice' && status !== 'upgrade-choice' && (
        <ResultScreen
          actions={[
            { label: 'Run Again', onClick: resetRun },
            { label: `Leave ${contentName}`, onClick: onExit, className: 'secondary-result-button' },
          ]}
          eyebrow={status === 'won' ? `${contentName} Complete` : 'Party Defeated'}
          log={log}
          onToggleLog={() => setShowEndLog((value) => !value)}
          showLog={showEndLog}
          title={status === 'won' ? 'The Warden Falls' : 'The Ashes Claim You'}
        >
            {status === 'won' ? (
              <div className="reward-summary">
                {!reward && !rewardError && <p>Recording victory...</p>}
                {rewardError && <p className="reward-error">{rewardError}</p>}
                {reward && (
                  <>
                    <RewardXpSummary reward={reward} />
                    {!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>
                        <LootRollList rolls={lootRolls} expectedRolls={expectedLootRolls} />
                        <BonusItemReward item={reward.bonusItem} eyebrow="Full Run Bonus" />
                      </>
                    )}
                  </>
                )}
              </div>
            ) : isRoguelike ? (
              <div className="reward-summary">
                {!reward && !rewardError && <p>Recording roguelike progress...</p>}
                {rewardError && <p className="reward-error">{rewardError}</p>}
                {reward && (
                  <>
                    <RewardXpSummary reward={reward} />
                    <p>{encounterIndex} encounters cleared.</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>
            )}
        </ResultScreen>
      )}
      {status === 'marathon-choice' && (
        <div className="result-screen">
          <div>
            <p className="eyebrow">Marathon</p>
            <h2>{encounter.enemyName} Defeated</h2>
            <p>
              {marathonBossesDefeated} boss{marathonBossesDefeated === 1 ? '' : 'es'} defeated.
              Continue with current health and {gameClass.resourceName}, or end the hunt.
            </p>
            <button
              onClick={() => {
                const current = combatRef.current
                setCombat({
                  ...current,
                  enemyHealth: encounter.maxHealth * enemyCount,
                  elapsedTicks: 0,
                })
                setStatus('playing')
                addLog(`Marathon continues. Another ${encounter.enemyName} appears.`, 'danger')
              }}
              type="button"
            >
              Continue Marathon
            </button>
            <button className="secondary-result-button" onClick={() => finishRun(currentPart, startPart)} type="button">
              End
            </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?` : 'Run checkpoint 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>
  )
}
