import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import * as THREE from 'three'
import {
  addBirdMesh,
  addBoarMesh,
  cancelCombatRenderLoads,
  clearCombatRenderCaches,
  addClaudeCraftBossModel,
  addCyberDragonMesh,
  addCombatUnitMesh,
  animateCombatWeapon,
  updateCombatUnitModel,
} from '../action3dCombatRender'
import {
  ACTION_ARENA_SCALE,
  ACTION_PLAYER_KEYS,
  ACTION_RENDER_QUALITY,
  clearCombatActions,
  createActionCombatCamera,
  createActionCombatCameraController,
  createHealBurstEffect,
  getCircleTelegraph,
  getLineTelegraph,
  pruneObjectMap,
  readCombatInput,
  resizeActionCombatCamera,
  setObjectOpacity,
  setupActionSceneEnvironment,
  syncLineTelegraphGeometry,
  updateActionCombatCamera,
} from '../action3dSceneKit'
import {
  getActionSpellbook,
  getActionSpellDefinition,
  getActionSpellIconUrl,
  getActionSpellTarget,
  getActionSpellTooltip,
} from '../actionCombatCore'
import {
  createArenaBattleState,
  getArenaBattleBosses,
  getArenaBattleFrames,
  getArenaBattleSpellManaCost,
  getArenaBattleUnits,
  updateArenaBattleState,
  type ArenaBossState,
  type ArenaBattleState,
  type ArenaBattleUnit,
} from '../actionArenaBattle'
import type { RaidFrame } from '../actionCombatTypes'
import type { SpellSlot } from '../actionCombatCore'
import type { ArenaUpgradeId } from '../actionArena'
import { getArenaAbility } from '../actionClassKits'
import type { ArenaClassId } from '../actionClassKits'
import type { ActionCharacter } from '../actionMode'
import { ActionCharacterMenu, ActionInventoryMenu, ActionMenuOverlay, ActionTalentMenu } from './ActionEquipmentMenus'
import type { TalentModifiers } from '../claudeCraftTalents'
import { createActionCombatProfile } from '../actionCombatProfile'
import { CombatActionBar, CombatPartyFrames, CombatTargetFrame, type CombatActionSlot } from './CombatHud'
import { PauseSettingsMenu } from './CombatPauseMenu'

type ArenaBattleSliceProps = {
  character?: ActionCharacter
  opponentName: string
  opponentUpgrades: ArenaUpgradeId[]
  playerName: string
  playerUpgrades: ArenaUpgradeId[]
  playerWins: number
  opponentWins: number
  round: number
  onEquipItem?: (itemId: string) => void
  onCharacterChange?: (character: ActionCharacter) => void
  onExit: () => void
  onRoundComplete: (winner: 'player' | 'opponent', elapsedSeconds: number) => void
}

type ArenaThreeHandle = {
  castSpell: (slot: SpellSlot) => void
  selectTarget: (targetId: string) => void
}

type UnitMesh = THREE.Group & {
  userData: {
    kind?: string
    role?: string
    teamId?: string
  }
}

type WallFrameMesh = THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial> & {
  userData: {
    canvas?: HTMLCanvasElement
    context?: CanvasRenderingContext2D | null
    texture?: THREE.CanvasTexture
  }
}

type ArenaTargetInfo = {
  id: string
  kind: 'ally' | 'boss'
  label: string
  detail: string
  hp: number
  maxHp: number
}

export function ArenaBattleSlice({
  character,
  opponentUpgrades,
  onCharacterChange,
  onEquipItem,
  onExit,
  onRoundComplete,
  playerUpgrades,
  round,
}: ArenaBattleSliceProps) {
  const combatProfile = useMemo(() => createActionCombatProfile(character), [character])
  const playerClassId = combatProfile.classId as ArenaClassId
  const sceneRef = useRef<ArenaThreeHandle | null>(null)
  const completionSentRef = useRef(false)
  const [paused, setPaused] = useState(false)
  const [pausePanel, setPausePanel] = useState<'settings' | 'character' | 'inventory' | 'talents'>('settings')
  const [state, setState] = useState(() => createArenaBattleState(round, playerUpgrades, opponentUpgrades, playerClassId, combatProfile.talentModifiers))
  const frames = useMemo(() => getArenaBattleFrames(state), [state])
  const target = useMemo(() => getArenaTargetInfo(state), [state])

  useEffect(() => {
    if (state.result === 'playing' || completionSentRef.current) return
    completionSentRef.current = true
    onRoundComplete(state.result, state.elapsed)
  }, [onRoundComplete, state.elapsed, state.result])

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.repeat) return
      if (event.key === 'Escape') {
        event.preventDefault()
        setPausePanel('settings')
        setPaused((current) => !current)
        return
      }
      if (paused) return
      if (event.key === 'Tab') {
        event.preventDefault()
        sceneRef.current?.selectTarget(state.playerBoss.id)
        return
      }
      if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
        event.preventDefault()
        const selectedIndex = Math.max(0, frames.playerFrames.findIndex((frame) => frame.selected))
        const delta = event.key === 'ArrowDown' ? 1 : -1
        const nextFrame = frames.playerFrames[(selectedIndex + delta + frames.playerFrames.length) % frames.playerFrames.length]
        if (nextFrame) sceneRef.current?.selectTarget(nextFrame.id)
      }
      const spellKey = event.key === '0' ? 10 : Number(event.key)
      if (getActionSpellbook(state.player.classId).bar.includes(spellKey as SpellSlot)) {
        event.preventDefault()
        sceneRef.current?.castSpell(spellKey as SpellSlot)
      }
    }
    window.addEventListener('keydown', handleKeyDown)
    return () => window.removeEventListener('keydown', handleKeyDown)
  }, [frames.playerFrames, paused, state.player.classId, state.playerBoss.id])

  useEffect(() => {
    let frameId = 0
    let startPressed = false
    const pollGamepadStart = () => {
      const pressed = Array.from(navigator.getGamepads?.() ?? [])
        .some((gamepad) => gamepad?.buttons[9]?.pressed)
      if (pressed && !startPressed) {
        setPausePanel('settings')
        setPaused((current) => !current)
      }
      startPressed = pressed
      frameId = window.requestAnimationFrame(pollGamepadStart)
    }
    frameId = window.requestAnimationFrame(pollGamepadStart)
    return () => window.cancelAnimationFrame(frameId)
  }, [])

  return (
    <main className="boss-slice-shell arena-battle-shell">
      <section className="boss-slice-stage">
        <div className="boss-slice-layout arena-battle-layout">
          <div className="boss-playfield-panel">
            <div className="boss-playfield-frame">
              <div className="arena-boss-window-party" aria-label="Your party frames">
                <CombatPartyFrames
                  compact
                  frames={frames.playerFrames}
                  manaByFrameId={{ [state.player.id]: { value: state.player.mana, max: state.player.maxMana } }}
                  onSelect={(id) => sceneRef.current?.selectTarget(id)}
                />
              </div>
              {state.player.currentCast && (
                <div className="boss-castbar boss-field-castbar">
                  <div>
                    <strong>{getActionSpellDefinition(state.player.classId, state.player.currentCast.spell, state.player.talentModifiers).name}</strong>
                    <span>{state.player.currentCast.remaining.toFixed(1)}s</span>
                  </div>
                  <i>
                    <b
                      style={{
                        width: `${Math.max(0, Math.min(100, ((state.player.currentCast.total - state.player.currentCast.remaining) / state.player.currentCast.total) * 100))}%`,
                      }}
                    />
                  </i>
                </div>
              )}
              <ArenaThreeScene
                onStateChange={setState}
                opponentUpgrades={opponentUpgrades}
                paused={paused}
                playerClassId={playerClassId}
                playerTalentModifiers={combatProfile.talentModifiers}
                playerUpgrades={playerUpgrades}
                ref={sceneRef}
                round={round}
              />
              <CombatTargetFrame
                action={target.kind !== 'boss' ? { label: 'Target Boss', onClick: () => sceneRef.current?.selectTarget(state.playerBoss.id) } : undefined}
                detail={target.detail}
                hp={target.hp}
                kindLabel={target.kind === 'boss' ? 'Target Boss' : 'Target Ally'}
                label={target.label}
                maxHp={target.maxHp}
                tone={target.kind === 'boss' ? 'enemy' : 'ally'}
              />
              <CombatActionBar
                columns={getActionSpellbook(state.player.classId).bar.length}
                globalCooldown={state.player.globalCooldown}
                onCast={(slot) => sceneRef.current?.castSpell(slot)}
                slots={createArenaActionSlots(state, target.kind)}
              />
              {paused && (
                pausePanel === 'settings' ? (
                  <PauseSettingsMenu
                    onExit={onExit}
                    onOpenCharacter={character ? () => setPausePanel('character') : undefined}
                    onOpenInventory={character && onEquipItem ? () => setPausePanel('inventory') : undefined}
                    onOpenTalents={character ? () => setPausePanel('talents') : undefined}
                    onResume={() => setPaused(false)}
                  />
                ) : character && pausePanel === 'character' ? (
                  <ActionMenuOverlay
                    onBack={() => setPausePanel('settings')}
                    onClose={() => setPaused(false)}
                    title="Character"
                  >
                    <ActionCharacterMenu
                      character={character}
                      onOpenInventory={onEquipItem ? () => setPausePanel('inventory') : undefined}
                      onOpenTalents={() => setPausePanel('talents')}
                    />
                  </ActionMenuOverlay>
                ) : character && pausePanel === 'talents' ? (
                  <ActionMenuOverlay
                    onBack={() => setPausePanel('settings')}
                    onClose={() => setPaused(false)}
                    title="Talents"
                  >
                    <ActionTalentMenu character={character} onCharacterChange={onCharacterChange} />
                  </ActionMenuOverlay>
                ) : character && onEquipItem ? (
                  <ActionMenuOverlay
                    onBack={() => setPausePanel('settings')}
                    onClose={() => setPaused(false)}
                    title="Inventory"
                  >
                    <ActionInventoryMenu character={character} onEquip={onEquipItem} />
                  </ActionMenuOverlay>
                ) : null
              )}
            </div>
          </div>
        </div>
      </section>
    </main>
  )
}

function getArenaTargetInfo(state: ArenaBattleState): ArenaTargetInfo {
  if (state.targetId === state.playerBoss.id) {
    return {
      detail: `${Math.ceil(state.playerBoss.hp)} / ${state.playerBoss.maxHp}`,
      hp: state.playerBoss.hp,
      id: state.playerBoss.id,
      kind: 'boss',
      label: state.playerBoss.name,
      maxHp: state.playerBoss.maxHp,
    }
  }

  const ally = [state.player, ...state.playerParty].find((unit) => unit.id === state.targetId) ?? state.player
  return {
    detail: `${ally.role.toUpperCase()} · ${Math.ceil(ally.hp)} / ${ally.maxHp}`,
    hp: ally.hp,
    id: ally.id,
    kind: 'ally',
    label: ally.name,
    maxHp: ally.maxHp,
  }
}

function createArenaActionSlots(state: ArenaBattleState, targetKind: ArenaTargetInfo['kind']): Array<CombatActionSlot<SpellSlot>> {
  return getActionSpellbook(state.player.classId).bar.map((slot) => {
    const spell = getActionSpellDefinition(state.player.classId, slot, state.player.talentModifiers)
    const manaCost = getArenaBattleSpellManaCost(state.playerUpgrades, slot, state.player.storedMomentumReady, state.player.classId, state.player.talentModifiers)
    const canAfford = state.player.mana >= manaCost
    const tooltip = getActionSpellTooltip(state.player.classId, slot)
    const requiresBoss = getActionSpellTarget(state.player.classId, slot) === 'Enemy target'
    const hasTarget = !requiresBoss || targetKind === 'boss'
    return {
      canCast: canAfford && hasTarget,
      cooldown: state.player.spellCooldowns[slot],
      cooldownBase: spell.cooldown,
      globalClassName: `${!canAfford ? 'oom' : ''} ${!hasTarget ? 'no-target' : ''}`,
      iconUrl: getActionSpellIconUrl(state.player.classId, slot),
      id: slot,
      keybind: slot === 10 ? '0' : String(slot),
      name: spell.name,
      tooltip: {
        cast: spell.castTime > 0 ? `${spell.castTime}s` : 'Instant',
        cooldown: spell.cooldown > 0 ? `${spell.cooldown}s` : undefined,
        cost: manaCost === 0 ? 'Free' : `${manaCost} mana`,
        description: tooltip.description,
        rank: `Rank ${tooltip.rank} · highest at level ${state.pvpEffectiveLevel}`,
        school: tooltip.school,
        target: tooltip.target,
      },
    }
  })
}

const ArenaThreeScene = forwardRef<ArenaThreeHandle, {
  opponentUpgrades: ArenaUpgradeId[]
  paused: boolean
  playerClassId?: ArenaClassId
  playerTalentModifiers?: TalentModifiers
  playerUpgrades: ArenaUpgradeId[]
  round: number
  onStateChange: (state: ArenaBattleState) => void
}>(function ArenaThreeScene({
  opponentUpgrades,
  onStateChange,
  paused,
  playerClassId = 'priest',
  playerTalentModifiers,
  playerUpgrades,
  round,
}, ref) {
  const mountRef = useRef<HTMLDivElement | null>(null)
  const stateRef = useRef(createArenaBattleState(round, playerUpgrades, opponentUpgrades, playerClassId, playerTalentModifiers))
  const queuedTargetRef = useRef<string | null>(null)
  const queuedSpellRef = useRef<SpellSlot | null>(null)
  const actionQueueRef = useRef({ reset: false })
  const keysRef = useRef(new Set<string>())
  const pausedRef = useRef(paused)
  const cameraControllerRef = useRef(createActionCombatCameraController())

  useImperativeHandle(ref, () => ({
    castSpell: (slot) => {
      queuedSpellRef.current = slot
    },
    selectTarget: (targetId) => {
      queuedTargetRef.current = targetId
    },
  }))

  useEffect(() => {
    pausedRef.current = paused
    if (paused) {
      queuedTargetRef.current = null
      queuedSpellRef.current = null
      clearCombatActions(actionQueueRef.current)
    }
  }, [paused])

  useEffect(() => {
    stateRef.current = createArenaBattleState(round, playerUpgrades, opponentUpgrades, playerClassId, playerTalentModifiers)
    onStateChange(stateRef.current)
  }, [onStateChange, opponentUpgrades, playerClassId, playerTalentModifiers, playerUpgrades, round])

  useEffect(() => {
    const mount = mountRef.current
    if (!mount) return

    const scene = new THREE.Scene()
    const renderer = new THREE.WebGLRenderer({ antialias: ACTION_RENDER_QUALITY.antialias, powerPreference: 'high-performance' })
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, ACTION_RENDER_QUALITY.maxPixelRatio))
    renderer.shadowMap.enabled = ACTION_RENDER_QUALITY.shadows
    renderer.shadowMap.type = THREE.PCFSoftShadowMap
    mount.appendChild(renderer.domElement)

    const camera = createActionCombatCamera({ far: 80 })
    const initialState = stateRef.current
    setupActionSceneEnvironment(scene, {
      divider: true,
      worldHeight: initialState.arena.height / ACTION_ARENA_SCALE,
      worldWidth: initialState.arena.width / ACTION_ARENA_SCALE,
    })

    const units = new Map<string, UnitMesh>()
    const walls = new Map<string, THREE.Mesh>()
    const wallFrames = new Map<string, WallFrameMesh>()
    const telegraphs = new Map<string, THREE.Mesh>()
    const slamTelegraphs = new Map<string, THREE.Mesh>()
    const birds = new Map<string, THREE.Group>()
    const birdLines = new Map<string, THREE.Mesh>()
    const effects = new Map<string, THREE.Group>()
    const clock = new THREE.Clock()
    let frameId = 0
    let hudTimer = 0

    const resize = () => {
      const rect = mount.getBoundingClientRect()
      renderer.setSize(Math.max(1, rect.width), Math.max(1, rect.height), false)
      resizeActionCombatCamera(camera, rect.width, rect.height)
    }
    const observer = new ResizeObserver(resize)
    observer.observe(mount)
    resize()

    const animate = () => {
      const delta = Math.min(clock.getDelta(), 0.05)
      const input = readCombatInput(keysRef.current, cameraControllerRef.current.yaw, queuedTargetRef.current, queuedSpellRef.current, actionQueueRef.current, {
        fallbackAim: { x: 1, y: 0 },
      })
      queuedTargetRef.current = null
      queuedSpellRef.current = null
      clearCombatActions(actionQueueRef.current)

      if (!pausedRef.current) stateRef.current = updateArenaBattleState(stateRef.current, input, delta)
      else clearCombatActions(actionQueueRef.current)
      syncArenaWorld(scene, stateRef.current, units, walls, wallFrames, telegraphs, slamTelegraphs, birds, birdLines, effects, camera, delta, playerClassId)
      updateActionCombatCamera(camera, cameraControllerRef.current.yaw, stateRef.current.player, (x, y) => toArenaWorld(stateRef.current, x, y))
      renderer.render(scene, camera)

      hudTimer -= delta
      if (hudTimer <= 0 || stateRef.current.result !== 'playing' || stateRef.current.lastHit) {
        onStateChange(stateRef.current)
        hudTimer = 0.08
      }
      frameId = requestAnimationFrame(animate)
    }
    animate()

    const handleKeyDown = (event: KeyboardEvent) => {
      const key = event.key.toLowerCase()
      if (ACTION_PLAYER_KEYS.has(key)) event.preventDefault()
      keysRef.current.add(key)
      if (!event.repeat && key === 'r') actionQueueRef.current.reset = true
    }
    const handleKeyUp = (event: KeyboardEvent) => {
      keysRef.current.delete(event.key.toLowerCase())
    }
    const unbindCameraDrag = cameraControllerRef.current.bindDrag(renderer.domElement)

    window.addEventListener('keydown', handleKeyDown)
    window.addEventListener('keyup', handleKeyUp)

    return () => {
      cancelAnimationFrame(frameId)
      observer.disconnect()
      window.removeEventListener('keydown', handleKeyDown)
      window.removeEventListener('keyup', handleKeyUp)
      unbindCameraDrag()
      cancelCombatRenderLoads(scene)
      scene.traverse((object) => {
        if (object instanceof THREE.Mesh) {
          object.geometry.dispose()
          const material = object.material
          if (Array.isArray(material)) material.forEach((item) => item.dispose())
          else material.dispose()
        }
      })
      renderer.dispose()
      renderer.domElement.remove()
      clearCombatRenderCaches()
    }
  }, [onStateChange, opponentUpgrades, playerClassId, playerTalentModifiers, playerUpgrades, round])

  return (
    <div className="three-action-root">
      <div className="three-action-canvas" ref={mountRef} />
    </div>
  )
})

function syncArenaWorld(
  scene: THREE.Scene,
  state: ArenaBattleState,
  units: Map<string, UnitMesh>,
  walls: Map<string, THREE.Mesh>,
  wallFrames: Map<string, WallFrameMesh>,
  telegraphs: Map<string, THREE.Mesh>,
  slamTelegraphs: Map<string, THREE.Mesh>,
  birds: Map<string, THREE.Group>,
  birdLines: Map<string, THREE.Mesh>,
  effects: Map<string, THREE.Group>,
  camera: THREE.PerspectiveCamera,
  deltaSeconds: number,
  playerClassId: ArenaClassId,
) {
  const toWorld = (x: number, y: number) => toArenaWorld(state, x, y)
  const activeUnits = new Set<string>()
  for (const unit of getArenaBattleUnits(state)) {
    activeUnits.add(unit.id)
    const mesh = getOrCreateArenaUnit(scene, units, unit, playerClassId)
    const point = toWorld(unit.x, unit.y)
    const prevX = Number(mesh.userData.prevArenaX ?? unit.x)
    const prevY = Number(mesh.userData.prevArenaY ?? unit.y)
    const moved = Math.hypot(unit.x - prevX, unit.y - prevY) > 0.08
    mesh.userData.prevArenaX = unit.x
    mesh.userData.prevArenaY = unit.y
    mesh.position.set(point.x, 0, point.z)
    mesh.visible = unit.hp > 0
    mesh.scale.setScalar(1)
    const weapon = mesh.getObjectByName('weapon')
    if (weapon) weapon.rotation.x = -0.18
    const boss = unit.teamId === 'player' ? state.playerBoss : state.opponentBoss
    const inMelee = boss.hp > 0 && Math.hypot(unit.x - boss.x, unit.y - boss.y) <= boss.radius + (unit.role === 'tank' ? 82 : 96)
    const activeTarget = unit.activeTargetId ? findArenaRenderTarget(state, unit.activeTargetId) : null
    const facingTarget = unit.id === 'player' ? null : activeTarget ?? (inMelee ? boss : null)
    mesh.rotation.y = unit.id === 'player'
      ? Math.atan2(state.player.facingX, state.player.facingY)
      : facingTarget
        ? Math.atan2(facingTarget.x - unit.x, facingTarget.y - unit.y)
        : unit.teamId === 'player' ? Math.PI / 2 : -Math.PI / 2
    animateCombatWeapon(mesh, unit.role, { elapsed: state.elapsed + unit.aiSlot * 0.17, inMelee })
    updateCombatUnitModel(mesh, deltaSeconds, {
      abilityId: unit.activeAbility,
      action: unit.id === 'player' && moved
        ? 'run'
        : inMelee && (unit.role === 'tank' || unit.role === 'melee')
          ? 'attack'
          : moved
            ? 'run'
            : 'idle',
      role: unit.id === 'player' ? 'player' : unit.role,
    })
  }
  for (const boss of getArenaBattleBosses(state)) {
    activeUnits.add(boss.id)
    const mesh = getOrCreateBossUnit(scene, units, boss)
    const point = toWorld(boss.x, boss.y)
    mesh.position.set(point.x, 0, point.z)
    mesh.visible = boss.hp > 0
    mesh.rotation.y = Math.atan2(boss.chargeVector.x, boss.chargeVector.y)
    const actionPulse = boss.activeAction === 'melee' || boss.activeAction === 'spell' || boss.activeAction === 'stomp'
      ? 1 + Math.sin(state.elapsed * 22) * 0.045
      : 1
    mesh.scale.setScalar((1 + (boss.spawnCount - 1) * 0.08) * actionPulse)
    syncBossTargetRing(mesh, boss, state.targetId === boss.id)
    updateCombatUnitModel(mesh, deltaSeconds, {
      action: boss.activeAction === 'run'
        ? 'run'
        : boss.activeAction === 'spell' || boss.activeAction === 'stomp'
          ? 'spell'
          : boss.activeAction === 'melee'
          ? 'attack'
          : 'idle',
      role: 'boss',
    })
  }
  pruneObjectMap(units, activeUnits)

  const activeTelegraphs = new Set<string>()
  for (const boss of getArenaBattleBosses(state)) {
    if (boss.telegraph.active) {
      activeTelegraphs.add(boss.id)
      const mesh = getLineTelegraph(scene, telegraphs, boss.id)
      syncLineTelegraphGeometry(mesh, boss.telegraph.start, boss.telegraph.end, boss.telegraph.width, (x, y) => toArenaWorld(state, x, y))
    }
  }
  pruneObjectMap(telegraphs, activeTelegraphs)

  const activeSlams = new Set<string>()
  for (const boss of getArenaBattleBosses(state)) {
    if (boss.slamTelegraph.active) {
      activeSlams.add(boss.id)
      const mesh = getCircleTelegraph(scene, slamTelegraphs, boss.id)
      const point = toWorld(boss.x, boss.y)
      mesh.position.set(point.x, 0.04, point.z)
      mesh.scale.setScalar(boss.slamTelegraph.radius / ACTION_ARENA_SCALE)
    }
  }
  pruneObjectMap(slamTelegraphs, activeSlams)

  const activeWalls = new Set<string>()
  for (const wall of state.walls) {
    activeWalls.add(wall.id)
    let mesh = walls.get(wall.id)
    if (!mesh) {
      mesh = new THREE.Mesh(
        new THREE.BoxGeometry(wall.width / ACTION_ARENA_SCALE, 1.8, wall.height / ACTION_ARENA_SCALE),
        new THREE.MeshStandardMaterial({ color: 0x596170, roughness: 0.72, transparent: true, opacity: 0.92 }),
      )
      mesh.castShadow = true
      mesh.receiveShadow = true
      scene.add(mesh)
      walls.set(wall.id, mesh)
    }
    const point = toWorld(wall.x, wall.y)
    mesh.position.set(point.x, wall.warning > 0 ? 0.18 : 0.9, point.z)
    mesh.scale.y = wall.warning > 0 ? 0.2 : 1
  }
  pruneObjectMap(walls, activeWalls)
  syncBossWallFrames(scene, wallFrames, state)
  syncWallPartyFrames(scene, wallFrames, state)

  const activeBirds = new Set<string>()
  const activeBirdLines = new Set<string>()
  for (const bird of state.birds) {
    activeBirds.add(bird.id)
    activeBirdLines.add(bird.id)
    let line = birdLines.get(bird.id)
    if (!line) {
      line = new THREE.Mesh(
        new THREE.PlaneGeometry(20, Math.max(0.42, (bird.radius * 2) / ACTION_ARENA_SCALE)),
        new THREE.MeshBasicMaterial({ color: 0xff3434, transparent: true, opacity: 0.36, side: THREE.DoubleSide }),
      )
      line.rotation.x = -Math.PI / 2
      scene.add(line)
      birdLines.set(bird.id, line)
    }
    const linePoint = toWorld(state.arena.width / 2, bird.y)
    line.position.set(0, 0.045, linePoint.z)
    const lineMaterial = line.material as THREE.MeshBasicMaterial
    lineMaterial.opacity = bird.warning > 0 ? 0.52 + Math.sin(state.elapsed * 18) * 0.16 : 0.24

    let mesh = birds.get(bird.id)
    if (!mesh) {
      mesh = new THREE.Group()
      addBirdMesh(mesh, 'bird')
      scene.add(mesh)
      birds.set(bird.id, mesh)
    }
    const point = toWorld(bird.x, bird.y)
    mesh.position.set(point.x, 1.05 + Math.sin(state.elapsed * 16) * 0.14, point.z)
    mesh.rotation.y = bird.vx > 0 ? Math.PI / 2 : -Math.PI / 2
    mesh.visible = bird.warning <= 0
  }
  pruneObjectMap(birds, activeBirds)
  pruneObjectMap(birdLines, activeBirdLines)

  syncArenaEffects(scene, state, effects, camera, deltaSeconds)
}

function toArenaWorld(state: ArenaBattleState, x: number, y: number) {
  return {
    x: (x - state.arena.width / 2) / ACTION_ARENA_SCALE,
    z: (y - state.arena.height / 2) / ACTION_ARENA_SCALE,
  }
}

function findArenaRenderTarget(state: ArenaBattleState, targetId: string) {
  return getArenaBattleUnits(state).find((unit) => unit.id === targetId)
    ?? getArenaBattleBosses(state).find((boss) => boss.id === targetId)
    ?? null
}

function getOrCreateArenaUnit(scene: THREE.Scene, units: Map<string, UnitMesh>, unit: ArenaBattleUnit, playerClassId: ArenaClassId) {
  const existing = units.get(unit.id)
  if (existing) return existing

  const group = new THREE.Group() as UnitMesh
  group.userData.role = unit.role
  group.userData.teamId = unit.teamId
  addHumanoidMesh(group, unit, playerClassId)
  scene.add(group)
  units.set(unit.id, group)
  return group
}

function getOrCreateBossUnit(scene: THREE.Scene, units: Map<string, UnitMesh>, boss: ArenaBossState) {
  const existing = units.get(boss.id)
  if (existing) return existing

  const group = new THREE.Group() as UnitMesh
  group.userData.kind = boss.kind
  group.userData.role = 'boss'
  group.userData.teamId = boss.teamId
  addBossMesh(group, boss)
  scene.add(group)
  units.set(boss.id, group)
  return group
}

function syncBossTargetRing(mesh: UnitMesh, boss: ArenaBossState, selected: boolean) {
  let ring = mesh.getObjectByName('targetRing') as THREE.Mesh<THREE.TorusGeometry, THREE.MeshBasicMaterial> | undefined
  if (!ring) {
    ring = new THREE.Mesh(
      new THREE.TorusGeometry(1, 0.035, 8, 48),
      new THREE.MeshBasicMaterial({ color: 0xffd86b, transparent: true, opacity: 0.9 }),
    )
    ring.name = 'targetRing'
    ring.rotation.x = Math.PI / 2
    ring.position.y = 0.07
    mesh.add(ring)
  }
  ring.visible = selected && boss.hp > 0
  ring.scale.setScalar(Math.max(0.78, boss.radius / 44))
}

function syncBossWallFrames(scene: THREE.Scene, wallFrames: Map<string, WallFrameMesh>, state: ArenaBattleState) {
  const bosses = [
    { boss: state.playerBoss, z: -7.18, label: 'YOUR BOSS' },
    { boss: state.opponentBoss, z: 7.18, label: 'RIVAL BOSS' },
  ]
  for (const { boss, label, z } of bosses) {
    const id = `${boss.id}-wall-boss`
    const mesh = getOrCreateWallFrame(scene, wallFrames, id)
    drawBossWallFrame(mesh, boss, label, state.targetId === boss.id)
    mesh.position.set(-0.145, 4.0, z)
    mesh.visible = true
  }
}

function syncWallPartyFrames(scene: THREE.Scene, wallFrames: Map<string, WallFrameMesh>, state: ArenaBattleState) {
  const frames = getArenaBattleFrames(state).opponentFrames
  const active = new Set<string>([
    `${state.playerBoss.id}-wall-boss`,
    `${state.opponentBoss.id}-wall-boss`,
  ])
  frames.forEach((frame, index) => {
    active.add(frame.id)
    const mesh = getOrCreateWallFrame(scene, wallFrames, frame.id)
    const mana = frame.id === 'opponent-healer'
      ? { value: state.opponentHealerResource.mana, max: state.opponentHealerResource.maxMana }
      : undefined
    drawWallFrame(mesh, frame, mana)
    mesh.position.set(-0.145, 3.02, -4.55 + index * 2.28)
    mesh.visible = true
  })
  pruneObjectMap(wallFrames, active)
}

function getOrCreateWallFrame(scene: THREE.Scene, wallFrames: Map<string, WallFrameMesh>, id: string) {
  const existing = wallFrames.get(id)
  if (existing) return existing

  const canvas = document.createElement('canvas')
  canvas.width = 320
  canvas.height = 116
  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  const material = new THREE.MeshBasicMaterial({
    map: texture,
    transparent: true,
    side: THREE.FrontSide,
  })
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2.28, 0.94), material) as WallFrameMesh
  mesh.rotation.y = -Math.PI / 2
  mesh.renderOrder = 4
  mesh.userData.canvas = canvas
  mesh.userData.context = canvas.getContext('2d')
  mesh.userData.texture = texture
  scene.add(mesh)
  wallFrames.set(id, mesh)
  return mesh
}

function drawWallFrame(mesh: WallFrameMesh, frame: RaidFrame, mana?: { value: number, max: number }) {
  const canvas = mesh.userData.canvas
  const context = mesh.userData.context
  if (!canvas || !context) return

  const hpPercent = Math.max(0, Math.min(1, frame.hp / frame.maxHp))
  const shieldPercent = Math.max(0, Math.min(1, frame.shield / frame.maxHp))
  const manaPercent = mana ? Math.max(0, Math.min(1, mana.value / mana.max)) : 0
  context.clearRect(0, 0, canvas.width, canvas.height)
  context.fillStyle = 'rgba(10, 12, 18, 0.82)'
  context.fillRect(0, 0, canvas.width, canvas.height)
  context.strokeStyle = 'rgba(94, 199, 255, 0.85)'
  context.lineWidth = 3
  context.strokeRect(2, 2, canvas.width - 4, canvas.height - 4)
  context.fillStyle = '#f0d06a'
  context.font = '700 14px monospace'
  context.fillText(frame.role.toUpperCase(), 14, 24)
  context.fillStyle = '#f6f2de'
  context.font = '700 18px monospace'
  context.fillText(truncateWallText(context, frame.name, 220), 14, 48)
  if (frame.role === 'melee' || frame.role === 'ranged') {
    context.fillStyle = 'rgba(255, 216, 107, 0.16)'
    context.fillRect(14, 55, 292, 22)
    context.strokeStyle = 'rgba(255, 216, 107, 0.58)'
    context.lineWidth = 1
    context.strokeRect(14, 55, 292, 22)
    context.fillStyle = '#ffdf7e'
    context.font = '700 16px monospace'
    context.fillText(`DAMAGE ${Math.round(frame.damageDone ?? 0).toLocaleString()}`, 22, 71)
  }
  drawFrameBar(context, 14, 82, 292, 12, hpPercent, '#76d39a')
  if (frame.shield > 0) drawFrameBar(context, 14, 82, 292, 12, Math.min(1, hpPercent + shieldPercent), '#5ec7ff', hpPercent)
  if (mana) drawFrameBar(context, 14, 98, 292, 8, manaPercent, '#247dff')
  context.fillStyle = '#c9ccdb'
  context.font = '700 13px monospace'
  context.textAlign = 'right'
  context.fillText(`${Math.ceil(frame.hp)}/${frame.maxHp}`, 306, 48)
  context.textAlign = 'left'
  if (mesh.userData.texture) mesh.userData.texture.needsUpdate = true
}

function truncateWallText(context: CanvasRenderingContext2D, text: string, maxWidth: number) {
  if (context.measureText(text).width <= maxWidth) return text
  let next = text
  while (next.length > 4 && context.measureText(`${next}...`).width > maxWidth) next = next.slice(0, -1)
  return `${next}...`
}

function drawBossWallFrame(mesh: WallFrameMesh, boss: ArenaBossState, label: string, selected: boolean) {
  const canvas = mesh.userData.canvas
  const context = mesh.userData.context
  if (!canvas || !context) return

  const hpPercent = Math.max(0, Math.min(1, boss.hp / boss.maxHp))
  context.clearRect(0, 0, canvas.width, canvas.height)
  context.fillStyle = 'rgba(10, 12, 18, 0.9)'
  context.fillRect(0, 0, canvas.width, canvas.height)
  context.strokeStyle = selected ? '#ffd86b' : '#ff6b84'
  context.lineWidth = selected ? 5 : 3
  context.strokeRect(3, 3, canvas.width - 6, canvas.height - 6)
  context.fillStyle = '#f0d06a'
  context.font = '700 13px monospace'
  context.fillText(label, 14, 24)
  context.fillStyle = '#f6f2de'
  context.font = '700 17px monospace'
  context.fillText(boss.name, 14, 48)
  drawFrameBar(context, 14, 60, 292, 16, hpPercent, '#e84c61')
  context.fillStyle = '#c9ccdb'
  context.font = '700 13px monospace'
  context.textAlign = 'right'
  context.fillText(`${Math.ceil(boss.hp)}/${boss.maxHp}`, 306, 49)
  context.textAlign = 'left'
  if (mesh.userData.texture) mesh.userData.texture.needsUpdate = true
}

function drawFrameBar(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  percent: number,
  color: string,
  startPercent = 0,
) {
  context.fillStyle = '#05070b'
  if (startPercent === 0) context.fillRect(x, y, width, height)
  context.fillStyle = color
  context.fillRect(x + width * startPercent, y, Math.max(0, width * (percent - startPercent)), height)
}

function addBossMesh(group: THREE.Group, boss: ArenaBossState) {
  const fallback = new THREE.Group()
  fallback.name = 'proceduralFallback'
  group.add(fallback)
  if (boss.kind === 'ysolei' || boss.kind === 'korzul-the-gravewyrm') {
    addCyberDragonMesh(fallback)
    addClaudeCraftBossModel(group, boss.kind)
    return
  }
  if (boss.kind === 'deacon-varric' || boss.kind === 'grand-necromancer-velkhar' || boss.kind === 'vael-the-mistcaller') {
    addHumanoidBossFallback(fallback, boss.kind === 'vael-the-mistcaller' ? 0x48c9b0 : 0x6a5474)
    addClaudeCraftBossModel(group, boss.kind)
    return
  }
  addBoarMesh(fallback, {
    color: 0x8f6f46,
    kind: 'bulldrome',
  })
  addClaudeCraftBossModel(group, boss.kind)
}

function addHumanoidBossFallback(group: THREE.Group, color: number) {
  const robe = new THREE.MeshStandardMaterial({ color, roughness: 0.78 })
  const trim = new THREE.MeshStandardMaterial({ color: 0xd7c27a, roughness: 0.55 })
  const body = new THREE.Mesh(new THREE.CapsuleGeometry(0.32, 0.9, 8, 16), robe)
  body.position.y = 0.72
  body.castShadow = true
  group.add(body)

  const head = new THREE.Mesh(new THREE.SphereGeometry(0.22, 16, 10), trim)
  head.position.y = 1.45
  head.castShadow = true
  group.add(head)

  const staff = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.045, 1.55, 8), trim)
  staff.position.set(0.42, 0.86, 0.04)
  staff.rotation.z = -0.12
  staff.castShadow = true
  group.add(staff)
}

function addHumanoidMesh(group: THREE.Group, unit: ArenaBattleUnit, playerClassId: ArenaClassId) {
  addCombatUnitMesh(group, {
    classId: unit.id === 'player' ? playerClassId : unit.classId,
    id: unit.id,
    role: unit.id === 'player' ? 'player' : unit.role,
    teamId: unit.teamId,
  })
  const ring = new THREE.Mesh(
    new THREE.TorusGeometry(0.43, 0.018, 8, 28),
    new THREE.MeshBasicMaterial({ color: unit.teamId === 'player' ? 0x5ec7ff : 0xff6b84, transparent: true, opacity: 0.75 }),
  )
  ring.rotation.x = Math.PI / 2
  ring.position.y = 0.035
  group.add(ring)
}

function syncArenaEffects(scene: THREE.Scene, state: ArenaBattleState, effects: Map<string, THREE.Group>, camera: THREE.PerspectiveCamera, deltaSeconds: number) {
  const toWorld = (x: number, y: number) => toArenaWorld(state, x, y)
  for (const event of state.healEvents) {
    if (effects.has(event.id)) continue
    const burst = createHealBurstEffect({ simple: true, color: 0x7dff9d })
    burst.userData.targetId = event.targetId
    burst.userData.age = 0
    scene.add(burst)
    effects.set(event.id, burst)

    const textId = `${event.id}-heal-text`
    const text = createFloatingCombatText(`+${Math.round(event.amount)}`, 0x7dff9d)
    text.userData.targetId = event.targetId
    text.userData.age = 0
    text.userData.combatText = true
    text.userData.floatOffset = 0.28
    scene.add(text)
    effects.set(textId, text)
  }
  for (const event of state.damageEvents) {
    const textId = `${event.id}-damage-text`
    if (effects.has(textId)) continue
    const text = createFloatingCombatText(String(Math.round(event.amount)), event.teamId === 'player' ? 0xffe066 : 0xff6b84)
    text.userData.targetId = event.targetId
    text.userData.age = 0
    text.userData.combatText = true
    text.userData.floatOffset = event.teamId === 'player' ? -0.2 : 0.2
    scene.add(text)
    effects.set(textId, text)
  }
  for (const event of state.abilityEvents) {
    if (effects.has(event.id)) continue
    const burst = createHealBurstEffect({ simple: true, color: getAbilityEventColor(event.school) })
    burst.userData.targetId = event.targetId
    burst.userData.age = 0
    burst.userData.ability = true
    scene.add(burst)
    effects.set(event.id, burst)

    const ability = getArenaAbility(event.abilityId)
    const caster = findArenaRenderTarget(state, event.casterId)
    const target = findArenaRenderTarget(state, event.targetId)
    if (caster && target && (ability.animation === 'ranged' || ability.animation === 'spell')) {
      const projectileId = `${event.id}-projectile`
      const projectile = createAbilityProjectile(ability.animation, getAbilityEventColor(event.school))
      const start = toWorld(caster.x, caster.y)
      const end = toWorld(target.x, target.y)
      projectile.userData.age = 0
      projectile.userData.projectile = true
      projectile.userData.sourceX = start.x
      projectile.userData.sourceZ = start.z
      projectile.userData.targetX = end.x
      projectile.userData.targetZ = end.z
      projectile.userData.arc = ability.animation === 'ranged' ? 0.24 : 0.62
      scene.add(projectile)
      effects.set(projectileId, projectile)
    }
  }
  for (const unit of getArenaBattleUnits(state)) {
    if (unit.hp <= 0 || unit.stunTimer <= 0 || effects.has(`stun-${unit.id}`)) continue
    const effect = createHealBurstEffect({ simple: true, color: 0xffd36d })
    effect.userData.targetId = unit.id
    effect.userData.age = 0
    effect.userData.stun = true
    scene.add(effect)
    effects.set(`stun-${unit.id}`, effect)
  }

  const active = new Set<string>()
  for (const [id, effect] of effects) {
    const age = Number(effect.userData.age ?? 0) + deltaSeconds
    effect.userData.age = age
    if (effect.userData.projectile) {
      const lifetime = 0.62
      if (age > lifetime) continue
      active.add(id)
      const t = Math.max(0, Math.min(1, age / lifetime))
      const sourceX = Number(effect.userData.sourceX ?? 0)
      const sourceZ = Number(effect.userData.sourceZ ?? 0)
      const targetX = Number(effect.userData.targetX ?? sourceX)
      const targetZ = Number(effect.userData.targetZ ?? sourceZ)
      const arc = Number(effect.userData.arc ?? 0.42)
      effect.position.set(
        THREE.MathUtils.lerp(sourceX, targetX, t),
        0.68 + Math.sin(t * Math.PI) * arc,
        THREE.MathUtils.lerp(sourceZ, targetZ, t),
      )
      effect.rotation.y = Math.atan2(targetX - sourceX, targetZ - sourceZ)
      effect.scale.setScalar(0.72 + Math.sin(t * Math.PI) * 0.28)
      setObjectOpacity(effect, Math.max(0, 1 - t))
      continue
    }
    const target = getArenaBattleUnits(state).find((unit) => unit.id === effect.userData.targetId)
      ?? getArenaBattleBosses(state).find((boss) => boss.id === effect.userData.targetId)
    const lifetime = effect.userData.combatText ? 1.15 : 0.85
    if (!target || age > lifetime || (effect.userData.stun && (!('stunTimer' in target) || target.stunTimer <= 0))) continue
    active.add(id)
    const point = toWorld(target.x, target.y)
    if (effect.userData.combatText) {
      const offset = Number(effect.userData.floatOffset ?? 0)
      effect.position.set(point.x + offset, 1.65 + age * 0.75, point.z)
      effect.lookAt(camera.position)
      setObjectOpacity(effect, Math.max(0, 1 - age / lifetime))
      continue
    }
    effect.position.set(point.x, effect.userData.stun ? 1.42 : 0.08, point.z)
    effect.scale.setScalar(0.6 + age * 1.5)
    effect.rotation.y += deltaSeconds * 4
    setObjectOpacity(effect, Math.max(0, 1 - age / lifetime))
  }
  pruneObjectMap(effects, active)
}

function createFloatingCombatText(text: string, color: number) {
  const canvas = document.createElement('canvas')
  canvas.width = 256
  canvas.height = 96
  const context = canvas.getContext('2d')
  if (context) {
    context.clearRect(0, 0, canvas.width, canvas.height)
    context.font = '700 44px monospace'
    context.textAlign = 'center'
    context.textBaseline = 'middle'
    context.lineWidth = 8
    context.strokeStyle = 'rgba(0, 0, 0, 0.9)'
    context.strokeText(text, canvas.width / 2, canvas.height / 2)
    context.lineWidth = 3
    context.strokeStyle = 'rgba(255, 255, 255, 0.55)'
    context.strokeText(text, canvas.width / 2, canvas.height / 2)
    context.fillStyle = `#${color.toString(16).padStart(6, '0')}`
    context.fillText(text, canvas.width / 2, canvas.height / 2)
  }
  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  const material = new THREE.MeshBasicMaterial({
    depthTest: false,
    map: texture,
    side: THREE.DoubleSide,
    transparent: true,
  })
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1.35, 0.5), material)
  mesh.renderOrder = 12
  const group = new THREE.Group()
  group.add(mesh)
  return group
}

function createAbilityProjectile(kind: 'ranged' | 'spell', color: number) {
  const group = new THREE.Group()
  if (kind === 'ranged') {
    const wood = new THREE.MeshStandardMaterial({ color: 0x8a5a2f, roughness: 0.78 })
    const metal = new THREE.MeshStandardMaterial({ color: 0xf3f5ff, emissive: color, emissiveIntensity: 0.28, roughness: 0.32, metalness: 0.42 })
    const shaft = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.035, 0.74), wood)
    group.add(shaft)
    const tip = new THREE.Mesh(new THREE.ConeGeometry(0.065, 0.16, 8), metal)
    tip.position.z = -0.45
    tip.rotation.x = -Math.PI / 2
    group.add(tip)
    return group
  }

  const material = new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.92 })
  const core = new THREE.Mesh(new THREE.SphereGeometry(0.13, 14, 10), material)
  group.add(core)
  const ring = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.014, 8, 24), material)
  ring.rotation.x = Math.PI / 2
  group.add(ring)
  const trail = new THREE.Mesh(new THREE.ConeGeometry(0.08, 0.34, 10), material)
  trail.position.z = 0.25
  trail.rotation.x = Math.PI / 2
  group.add(trail)
  return group
}

function getAbilityEventColor(school: string) {
  if (school === 'fire') return 0xff7043
  if (school === 'frost') return 0x8edcff
  if (school === 'arcane') return 0xb184ff
  if (school === 'shadow') return 0x8e68c4
  if (school === 'nature') return 0x7ed957
  if (school === 'holy') return 0xfff1a8
  return 0xe5b95f
}
