// Pure 3D replay view. No DOM beyond its own <canvas>; driven entirely by an
// external clock (setTime) so it stays in sync with the 2D ReplayCanvasEngine.
// Ported from the standalone REPLAY_VIEWER three.js viewer, stripped of all of
// its own UI (labels, axes, guide lines, controls, hover/active-list panels).

import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'

const MODEL_PATH = '/models/t34/'
const MINIMAP_URL = (level) => `/api/match/minimap/${level}`
const MINIMAP_FULL_URL = (level) => `/api/match/minimap/${level}?type=full`

const WIN_COLOR = '#00c800'
const LOSE_COLOR = '#dc1e1e'
const DRONE_COLOR = '#cbd5e1'

const DIRECTION_SAMPLE_MS = 700
const DIRECTION_BLEND = 0.22
const KILL_LINE_WINDOW_MS = 3000
const FLIP_MAP_TEXTURE_Z = true

function coordsAreValid(c) {
  return c &&
    Number.isFinite(Number(c.x0)) && Number.isFinite(Number(c.z0)) &&
    Number.isFinite(Number(c.x1)) && Number.isFinite(Number(c.z1)) &&
    Number(c.x0) !== Number(c.x1) && Number(c.z0) !== Number(c.z1)
}

function normalizeCoords(c) {
  if (!coordsAreValid(c)) return null
  return { x0: Number(c.x0), z0: Number(c.z0), x1: Number(c.x1), z1: Number(c.z1) }
}

function mapSourceRect(baseCoords, renderCoords) {
  const base = normalizeCoords(baseCoords)
  const render = normalizeCoords(renderCoords)
  if (!base || !render) return { u: 0, v: 0, w: 1, h: 1 }
  const dx = base.x1 - base.x0
  const dz = base.z1 - base.z0
  const xLo = Math.min(render.x0, render.x1)
  const xHi = Math.max(render.x0, render.x1)
  const zLo = Math.min(render.z0, render.z1)
  const zHi = Math.max(render.z0, render.z1)
  const u0 = (xLo - base.x0) / dx
  const u1 = (xHi - base.x0) / dx
  const v0 = (zLo - base.z0) / dz
  const v1 = (zHi - base.z0) / dz
  const uMin = Math.max(0, Math.min(1, Math.min(u0, u1)))
  const uMax = Math.max(0, Math.min(1, Math.max(u0, u1)))
  const vMin = Math.max(0, Math.min(1, Math.min(v0, v1)))
  const vMax = Math.max(0, Math.min(1, Math.max(v0, v1)))
  const w = uMax - uMin
  const h = vMax - vMin
  if (!(w > 0 && h > 0)) return { u: 0, v: 0, w: 1, h: 1 }
  return { u: uMin, v: 1 - vMax, w, h }
}

function fallbackBoundsCoords(bounds) {
  const pad = Math.max(bounds.planarSpan * 0.15, 250)
  return { x0: bounds.minX - pad, z0: bounds.minZ - pad, x1: bounds.maxX + pad, z1: bounds.maxZ + pad }
}

function catmullRom(a, b, c, d, t) {
  const t2 = t * t
  const t3 = t2 * t
  return 0.5 * ((2 * b) + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t2 + (-a + 3 * b - 3 * c + d) * t3)
}

export default class ReplayCanvas3D {
  constructor(container, data) {
    this.container = container
    this.data = data
    this.disposed = false
    this.currentT = 0
    this.selectedPlayerId = null
    this.smoothByEntity = new Map()

    this.players = {}
    for (const p of data.players || []) this.players[p.id] = p
    this.teamWon = data.teamWon
    this.winnerSlot = Number(data.winnerSlot) || 0

    this._buildEntities()
    this._buildCaptureModel()
    this.kills = (data.kills || []).filter((k) => k.killerPos && k.victimPos)

    this.bounds = this._computeBounds()
    this.scale = 1200 / this.bounds.planarSpan

    this._initThree()

    this._mode = 'ground'
    this.mapInfo = this._resolveMapInfo('ground')

    this._buildScene()
    this._loadTankTemplate()
    this._loadMapTexture()

    this._animate = this._animate.bind(this)
    this._raf = requestAnimationFrame(this._animate)
    this.setTime(0)
  }

  // ---- data adaptation -----------------------------------------------------

  _isWinner(entity) {
    if (entity.playerId > 0) return this.players[entity.playerId]?.team === this.teamWon
    return entity.droneTeam === this.teamWon
  }

  _colorFor(entity) {
    if (entity.playerId === 0 && entity.type === 'drone') return DRONE_COLOR
    return this._isWinner(entity) ? WIN_COLOR : LOSE_COLOR
  }

  _buildEntities() {
    this.entities = []
    for (const e of this.data.entities || []) {
      if (!Array.isArray(e.path) || !e.path.length) continue
      const path = e.path.map((p) => ({ t: Number(p.t), x: Number(p.x), y: Number(p.y || 0), z: Number(p.z) }))
      this.entities.push({
        playerId: e.playerId,
        entityIndex: e.entityIndex,
        type: e.type,
        droneTeam: e.droneTeam,
        path,
        startT: path[0].t,
        endT: path[path.length - 1].t,
      })
    }
  }

  _buildCaptureModel() {
    const areas = Array.isArray(this.data.captureAreas) ? this.data.captureAreas : []
    const state = this.data.captureState || {}
    this.captureZones = areas.map((area, index) => {
      const letter = String.fromCharCode(65 + index)
      const cap = Array.isArray(state[letter])
        ? state[letter].map(([t, v]) => [Number(t), Number(v)]).filter(([t, v]) => Number.isFinite(t) && Number.isFinite(v))
        : []
      const center = area.tm?.center || [area.x, 0, area.z]
      return {
        key: letter,
        center: { x: Number(area.x ?? center[0]), y: Number(center[1] || 0), z: Number(area.z ?? center[2]) },
        radius: Math.max(1, Number(area.radius) || 1),
        cap,
      }
    })
  }

  _computeBounds() {
    const b = { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity, minZ: Infinity, maxZ: -Infinity }
    for (const e of this.entities) {
      for (const p of e.path) {
        b.minX = Math.min(b.minX, p.x); b.maxX = Math.max(b.maxX, p.x)
        b.minY = Math.min(b.minY, p.y); b.maxY = Math.max(b.maxY, p.y)
        b.minZ = Math.min(b.minZ, p.z); b.maxZ = Math.max(b.maxZ, p.z)
      }
    }
    if (!Number.isFinite(b.minX)) { b.minX = 0; b.maxX = 1; b.minY = 0; b.maxY = 1; b.minZ = 0; b.maxZ = 1 }
    b.centerX = (b.minX + b.maxX) / 2
    b.centerZ = (b.minZ + b.maxZ) / 2
    b.spanX = Math.max(1, b.maxX - b.minX)
    b.spanZ = Math.max(1, b.maxZ - b.minZ)
    b.planarSpan = Math.max(b.spanX, b.spanZ)
    return b
  }

  _resolveMapInfo(mode) {
    const level = this.data.mission?.level
    const full = this.data.fullMapLevel
    if (mode === 'air' && full && normalizeCoords(this.data.mapCoords)) {
      const coords = normalizeCoords(this.data.mapCoords)
      return { image: MINIMAP_FULL_URL(full), coords, baseCoords: coords, sourceRect: { u: 0, v: 0, w: 1, h: 1 } }
    }
    const coords = normalizeCoords(this.data.levelCoords) || fallbackBoundsCoords(this.bounds)
    const baseCoords = normalizeCoords(this.data.tankMapCoords) || coords
    return {
      image: level ? MINIMAP_URL(level) : null,
      coords,
      baseCoords,
      sourceRect: mapSourceRect(baseCoords, coords),
    }
  }

  // ---- coordinate transforms ----------------------------------------------

  _toWorld(p) {
    const b = this.bounds
    return new THREE.Vector3(
      (p.x - b.centerX) * this.scale,
      (p.y - b.minY) * this.scale,
      (p.z - b.centerZ) * this.scale,
    )
  }

  _renderPoint(p) {
    if (!FLIP_MAP_TEXTURE_Z) return p
    const coords = normalizeCoords(this.mapInfo?.coords)
    if (!coords) return p
    return { ...p, z: coords.z0 + coords.z1 - p.z }
  }

  // ---- three.js setup ------------------------------------------------------

  _initThree() {
    const canvas = document.createElement('canvas')
    canvas.className = 'rc3d-canvas'
    this.canvasEl = canvas
    this.container.appendChild(canvas)

    this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
    this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
    this.renderer.setClearColor(0x0b0d10, 1)

    this.scene = new THREE.Scene()
    this.camera = new THREE.PerspectiveCamera(55, 1, 0.1, 20000)
    this.controls = new OrbitControls(this.camera, this.renderer.domElement)
    this.controls.enableDamping = true
    this.controls.dampingFactor = 0.08
    this.controls.screenSpacePanning = false
    this.controls.maxPolarAngle = Math.PI * 0.49
    this.controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE
    this.controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY
    this.controls.mouseButtons.RIGHT = THREE.MOUSE.PAN

    this.root = new THREE.Group()
    this.mapGroup = new THREE.Group()
    this.pathGroup = new THREE.Group()
    this.zoneGroup = new THREE.Group()
    this.killLineGroup = new THREE.Group()
    this.markerGroup = new THREE.Group()
    this.root.add(this.mapGroup, this.pathGroup, this.zoneGroup, this.killLineGroup, this.markerGroup)
    this.scene.add(this.root)

    this.scene.add(new THREE.AmbientLight(0xffffff, 0.72))
    const sun = new THREE.DirectionalLight(0xffffff, 1.1)
    sun.position.set(240, 500, 180)
    this.scene.add(sun)

    this.resize()
  }

  _worldMapExtents(coords = this.mapInfo?.coords) {
    const c = normalizeCoords(coords)
    if (!c) return null
    const a = this._toWorld({ x: c.x0, y: this.bounds.minY, z: c.z0 })
    const b = this._toWorld({ x: c.x1, y: this.bounds.minY, z: c.z1 })
    return {
      minX: Math.min(a.x, b.x), maxX: Math.max(a.x, b.x),
      minZ: Math.min(a.z, b.z), maxZ: Math.max(a.z, b.z),
      width: Math.abs(b.x - a.x), depth: Math.abs(b.z - a.z),
    }
  }

  _resetCamera() {
    const ext = this._worldMapExtents()
    const span = Math.max(ext?.width || 1400, ext?.depth || 1400, 100)
    this.camera.position.set(span * 0.45, span * 0.55, span * 0.72)
    this.controls.target.set(0, 0, 0)
    this.controls.minDistance = Math.max(18, span * 0.025)
    this.controls.maxDistance = Math.max(320, span * 1.8)
    this.camera.far = Math.max(9000, span * 8)
    this.camera.updateProjectionMatrix()
    this.controls.update()
  }

  // ---- scene construction --------------------------------------------------

  _buildScene() {
    this._clearGroup(this.mapGroup)
    this._clearGroup(this.pathGroup)
    this._clearGroup(this.markerGroup)
    this._clearGroup(this.zoneGroup)
    this._clearGroup(this.killLineGroup)
    this._buildMapPlane()
    this._buildCaptureZones()
    this._buildKillLines()
    this.visualEntities = this.entities.map((e) => this._makeVisualEntity(e))
    this._applyHighlight()
    this._resetCamera()
  }

  _clearGroup(group) {
    for (const child of [...group.children]) {
      group.remove(child)
      child.traverse?.((c) => {
        if (c.geometry) c.geometry.dispose()
        if (c.material) (Array.isArray(c.material) ? c.material : [c.material]).forEach((m) => m.dispose())
      })
    }
  }

  _buildMapPlane() {
    const info = this.mapInfo
    if (!info || !coordsAreValid(info.coords)) return
    const x0 = Math.min(info.coords.x0, info.coords.x1)
    const x1 = Math.max(info.coords.x0, info.coords.x1)
    const z0 = Math.min(info.coords.z0, info.coords.z1)
    const z1 = Math.max(info.coords.z0, info.coords.z1)
    const width = Math.max(1, (x1 - x0) * this.scale)
    const depth = Math.max(1, (z1 - z0) * this.scale)
    const center = this._toWorld({ x: (x0 + x1) / 2, y: this.bounds.minY, z: (z0 + z1) / 2 })

    const geometry = new THREE.PlaneGeometry(width, depth, 1, 1)
    this._applyPlaneUvs(geometry, info.sourceRect || { u: 0, v: 0, w: 1, h: 1 })
    geometry.rotateX(-Math.PI / 2)

    const material = new THREE.MeshBasicMaterial({
      color: 0x18202a, transparent: true, opacity: 0.92, depthWrite: false, side: THREE.DoubleSide,
    })
    const plane = new THREE.Mesh(geometry, material)
    plane.position.set(center.x, -0.12, center.z)
    plane.renderOrder = -10
    this.mapGroup.add(plane)
    this._mapMaterial = material
  }

  _applyPlaneUvs(geometry, rect) {
    const u0 = rect.u
    const u1 = rect.u + rect.w
    const vTop = rect.v
    const vBottom = rect.v + rect.h
    const uv = geometry.attributes.uv
    if (FLIP_MAP_TEXTURE_Z) {
      uv.setXY(0, u0, 1 - vTop); uv.setXY(1, u1, 1 - vTop)
      uv.setXY(2, u0, 1 - vBottom); uv.setXY(3, u1, 1 - vBottom)
    } else {
      uv.setXY(0, u0, 1 - vBottom); uv.setXY(1, u1, 1 - vBottom)
      uv.setXY(2, u0, 1 - vTop); uv.setXY(3, u1, 1 - vTop)
    }
    uv.needsUpdate = true
  }

  _loadMapTexture() {
    if (!this.mapInfo.image || !this._mapMaterial) return
    const material = this._mapMaterial
    new THREE.TextureLoader().load(
      this.mapInfo.image,
      (texture) => {
        if (this.disposed) { texture.dispose(); return }
        texture.colorSpace = THREE.SRGBColorSpace
        texture.anisotropy = this.renderer.capabilities.getMaxAnisotropy()
        material.map = texture
        material.color.setHex(0xffffff)
        material.needsUpdate = true
      },
      undefined,
      () => { material.color.setHex(0x1f2933); material.opacity = 0.6 },
    )
  }

  _makeVisualEntity(entity) {
    const color = this._colorFor(entity)
    const points = entity.path.map((p) => this._toWorld(this._renderPoint(p)))
    const lineGeometry = new THREE.BufferGeometry().setFromPoints(points)
    const lineMaterial = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.42 })
    const line = new THREE.Line(lineGeometry, lineMaterial)
    this.pathGroup.add(line)

    const radius = 4.4
    let model
    if (this.unitTemplate && entity.type === 'ground') {
      model = this.unitTemplate.clone(true)
      model.traverse((child) => {
        if (!child.isMesh) return
        child.material = new THREE.MeshStandardMaterial({
          color, roughness: 0.72, metalness: 0.06, flatShading: true,
          emissive: new THREE.Color(color).multiplyScalar(0.22),
        })
      })
    } else {
      const geo = new THREE.SphereGeometry(radius, 18, 12)
      const mat = new THREE.MeshStandardMaterial({
        color, emissive: new THREE.Color(color).multiplyScalar(0.24), roughness: 0.45, metalness: 0.08,
      })
      model = new THREE.Mesh(geo, mat)
    }

    const arrow = new THREE.ArrowHelper(
      new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 0),
      radius * 4.6, new THREE.Color(color), radius * 1.55, radius * 0.9,
    )
    arrow.line.material.transparent = true
    arrow.line.material.opacity = 0.78
    arrow.cone.material.transparent = true
    arrow.cone.material.opacity = 0.95

    const group = new THREE.Group()
    group.add(model, arrow)
    this.markerGroup.add(group)

    return { entity, line, group, model, arrow, radius, smoothedDirection: null, lastDirectionT: null }
  }

  async _loadTankTemplate() {
    try {
      const object = await new Promise((resolve, reject) => {
        const loader = new OBJLoader()
        loader.setPath(MODEL_PATH)
        loader.load('t_34_obj.obj', resolve, undefined, reject)
      })
      if (this.disposed) return

      const planes = []
      object.traverse((child) => {
        if (!child.isMesh) return
        child.castShadow = false
        child.receiveShadow = false
        if (/^Plane/i.test(child.name || '')) { planes.push(child); return }
      })
      for (const plane of planes) plane.removeFromParent()

      const box = new THREE.Box3()
      object.traverse((child) => { if (child.isMesh) box.expandByObject(child) })
      if (box.isEmpty()) box.setFromObject(object)
      const size = box.getSize(new THREE.Vector3())
      const maxDim = Math.max(size.x, size.y, size.z, 1)
      const scale = 70 / maxDim
      const center = box.getCenter(new THREE.Vector3())

      const pivot = new THREE.Group()
      object.position.x -= center.x
      object.position.z -= center.z
      object.position.y -= box.min.y
      pivot.add(object)
      pivot.scale.setScalar(scale)
      this.unitTemplate = pivot

      // Rebuild ground entities now that the model is available.
      this._buildScene()
      this.setTime(this.currentT)
    } catch {
      // No model -> spheres are used; nothing else to do.
    }
  }

  // ---- capture zones -------------------------------------------------------

  _buildCaptureZones() {
    for (const zone of this.captureZones) {
      const center = this._toWorld(this._renderPoint(zone.center))
      const radius = Math.max(8, zone.radius * this.scale)
      const group = new THREE.Group()
      group.position.set(center.x, 1.05, center.z)

      const ringGeo = new THREE.RingGeometry(radius * 0.92, radius, 96)
      ringGeo.rotateX(-Math.PI / 2)
      const ring = new THREE.Mesh(ringGeo, new THREE.MeshBasicMaterial({
        color: 0xdde6ee, transparent: true, opacity: 0.7, depthWrite: false, side: THREE.DoubleSide,
      }))
      ring.renderOrder = 8

      const fillGeo = new THREE.CircleGeometry(radius * 0.82, 96)
      fillGeo.rotateX(-Math.PI / 2)
      const fill = new THREE.Mesh(fillGeo, new THREE.MeshBasicMaterial({
        color: 0xd8dee9, transparent: true, opacity: 0, depthWrite: false, side: THREE.DoubleSide,
      }))
      fill.position.y = 0.02
      fill.renderOrder = 7

      group.add(fill, ring)
      group.userData = { zone, fill }
      this.zoneGroup.add(group)
    }
  }

  // Step interpolation, matching the 2D engine's _interpSeries(series, t, true).
  _captureValueAt(zone, t) {
    const cap = zone.cap
    if (!cap.length) return 0
    if (t <= cap[0][0]) return cap[0][1]
    const last = cap[cap.length - 1]
    if (t >= last[0]) return last[1]
    for (let i = 1; i < cap.length; i++) {
      if (cap[i][0] >= t) return cap[i - 1][1]
    }
    return last[1]
  }

  _updateCaptureZones() {
    for (const group of this.zoneGroup.children) {
      const { zone, fill } = group.userData
      if (!zone || !fill) continue
      // Same logic as the 2D engine: owner is slot 2 if value>0 else slot 1,
      // coloured green when it matches the winning slot, red otherwise.
      const value = this._captureValueAt(zone, this.currentT)
      const frac = Math.min(1, Math.abs(value) / 100)
      if (frac <= 0.01) { fill.visible = false; continue }
      const ownerSlot = value > 0 ? 2 : 1
      fill.visible = true
      fill.material.color.set(ownerSlot === this.winnerSlot ? WIN_COLOR : LOSE_COLOR)
      fill.material.opacity = 0.18 + frac * 0.34
      const s = Math.sqrt(frac)
      fill.scale.set(s, s, 1)
    }
  }

  // ---- kill lines ----------------------------------------------------------

  _buildKillLines() {
    this.killLines = []
    for (const k of this.kills) {
      const start = this._toWorld(this._renderPoint({ x: k.killerPos.x, y: this.bounds.minY, z: k.killerPos.z }))
      const end = this._toWorld(this._renderPoint({ x: k.victimPos.x, y: this.bounds.minY, z: k.victimPos.z }))
      start.y += 12; end.y += 12
      const vec = end.clone().sub(start)
      const length = vec.length()
      if (length < 0.5) continue
      const color = new THREE.Color(this.players[k.killerId]?.team === this.teamWon ? WIN_COLOR : LOSE_COLOR)
      const arrow = new THREE.ArrowHelper(vec.clone().normalize(), start, length, color,
        Math.min(28, Math.max(10, length * 0.12)), Math.min(16, Math.max(7, length * 0.055)))
      for (const m of [arrow.line.material, arrow.cone.material]) {
        m.transparent = true; m.opacity = 0; m.depthTest = false; m.depthWrite = false
      }
      arrow.renderOrder = 18
      arrow.visible = false
      this.killLineGroup.add(arrow)
      this.killLines.push({ time: k.time, arrow })
    }
  }

  _updateKillLines() {
    for (const { time, arrow } of this.killLines) {
      const age = this.currentT - time
      const visible = age >= 0 && age <= KILL_LINE_WINDOW_MS
      arrow.visible = visible
      if (!visible) continue
      const opacity = 1 - age / KILL_LINE_WINDOW_MS
      arrow.line.material.opacity = 0.18 + opacity * 0.62
      arrow.cone.material.opacity = 0.24 + opacity * 0.72
    }
  }

  // ---- interpolation -------------------------------------------------------

  _findSegment(entity, t) {
    const path = entity.path
    if (t < entity.startT || t > entity.endT) return null
    let lo = 0, hi = path.length - 1
    while (lo < hi - 1) {
      const mid = (lo + hi) >> 1
      if (path[mid].t <= t) lo = mid; else hi = mid
    }
    return { index: lo, a: path[lo], b: path[Math.min(lo + 1, path.length - 1)] }
  }

  _interp(entity, t) {
    const seg = this._findSegment(entity, t)
    if (!seg) return null
    const { index, a, b } = seg
    const path = entity.path
    const span = Math.max(1, b.t - a.t)
    const alpha = Math.min(1, Math.max(0, (t - a.t) / span))
    const before = path[Math.max(0, index - 1)]
    const after = path[Math.min(path.length - 1, index + 2)]
    return {
      t,
      x: catmullRom(before.x, a.x, b.x, after.x, alpha),
      y: catmullRom(before.y, a.y, b.y, after.y, alpha),
      z: catmullRom(before.z, a.z, b.z, after.z, alpha),
    }
  }

  _directionAt(entity, t) {
    const beforeT = Math.max(entity.startT, t - DIRECTION_SAMPLE_MS)
    const afterT = Math.min(entity.endT, t + DIRECTION_SAMPLE_MS)
    if (afterT <= beforeT) return null
    const before = this._interp(entity, beforeT)
    const after = this._interp(entity, afterT)
    if (!before || !after) return null
    const dir = this._toWorld(this._renderPoint(after)).sub(this._toWorld(this._renderPoint(before)))
    if (dir.lengthSq() < 0.0001) return null
    return dir.normalize()
  }

  _blendedDirection(visual, t) {
    const raw = this._directionAt(visual.entity, t)
    if (!raw) return visual.smoothedDirection
    const reset = visual.lastDirectionT == null || Math.abs(t - visual.lastDirectionT) > 1800
    if (reset || !visual.smoothedDirection) visual.smoothedDirection = raw.clone()
    else visual.smoothedDirection.lerp(raw, DIRECTION_BLEND).normalize()
    visual.lastDirectionT = t
    return visual.smoothedDirection
  }

  // ---- highlight / focus ---------------------------------------------------

  _applyHighlight() {
    const sel = this.selectedPlayerId
    if (!this.visualEntities) return
    for (const visual of this.visualEntities) {
      const isSel = sel != null && visual.entity.playerId === sel
      const teamColor = this._colorFor(visual.entity)
      const mat = visual.line.material
      if (sel == null) {
        mat.opacity = 0.42; mat.color.set(teamColor); visual.line.renderOrder = 0
      } else if (isSel) {
        mat.opacity = 1; mat.color.set(teamColor).lerp(new THREE.Color(0xffffff), 0.35); visual.line.renderOrder = 30
      } else {
        mat.opacity = 0.08; mat.color.set(teamColor); visual.line.renderOrder = 0
      }
      if (visual.model?.isGroup) {
        visual.model.traverse((child) => {
          if (child.isMesh && child.material?.emissive) child.material.emissiveIntensity = isSel ? 2.6 : 1
        })
      }
    }
  }

  focus(playerId) {
    this.selectedPlayerId = this.selectedPlayerId === playerId ? null : playerId
    this._applyHighlight()
    if (this.selectedPlayerId == null) return
    const visual = (this.visualEntities || []).find((v) => v.entity.playerId === playerId && v.group.visible)
      || (this.visualEntities || []).find((v) => v.entity.playerId === playerId)
    if (!visual) return
    const p = this._interp(visual.entity, this.currentT)
    if (!p) return
    const world = this._toWorld(this._renderPoint(p))
    const offset = this.camera.position.clone().sub(this.controls.target)
    this.controls.target.copy(world)
    this.camera.position.copy(world.clone().add(offset))
    this.controls.update()
  }

  // ---- public API ----------------------------------------------------------

  setTime(t) {
    this.currentT = t
    if (!this.visualEntities) return
    for (const visual of this.visualEntities) {
      const p = this._interp(visual.entity, t)
      visual.group.visible = Boolean(p)
      if (!p) continue
      visual.group.position.copy(this._toWorld(this._renderPoint(p)))
      const dir = this._blendedDirection(visual, t)
      visual.arrow.visible = Boolean(dir)
      if (dir) {
        visual.arrow.setDirection(dir)
        if (visual.model?.isGroup) {
          const flat = dir.clone(); flat.y = 0
          if (flat.lengthSq() > 0) {
            flat.normalize()
            visual.model.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), flat)
            visual.model.rotateY(Math.PI)
            visual.model.rotateY(-Math.PI / 2)
          }
        }
      }
    }
    this._updateCaptureZones()
    this._updateKillLines()
  }

  setMode(mode) {
    const next = mode === 'air' && normalizeCoords(this.data.mapCoords) ? 'air' : 'ground'
    if (next === this._mode) return
    this._mode = next
    this.mapInfo = this._resolveMapInfo(next)
    this.smoothByEntity.clear()
    this._buildScene()
    this._loadMapTexture()
    this.setTime(this.currentT)
  }

  resize() {
    const w = this.container.clientWidth || this.container.offsetWidth || 720
    const h = this.container.clientHeight || w
    this.renderer.setSize(w, h, false)
    this.camera.aspect = w / Math.max(1, h)
    this.camera.updateProjectionMatrix()
  }

  _animate() {
    if (this.disposed) return
    this.controls.update()
    this.renderer.render(this.scene, this.camera)
    this._raf = requestAnimationFrame(this._animate)
  }

  dispose() {
    this.disposed = true
    if (this._raf) cancelAnimationFrame(this._raf)
    this.controls?.dispose()
    this._clearGroup(this.mapGroup)
    this._clearGroup(this.pathGroup)
    this._clearGroup(this.markerGroup)
    this._clearGroup(this.zoneGroup)
    this._clearGroup(this.killLineGroup)
    this.renderer?.dispose()
    if (this.canvasEl?.parentNode) this.canvasEl.parentNode.removeChild(this.canvasEl)
  }
}
