import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
import { useAnimations, useGLTF } from "@react-three/drei";
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject, type RefObject } from "react";
import * as THREE from "three";
import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { ARENA_CENTER, clampToArena } from "../game/arena";
import { BOSS_ARCHETYPE_BY_ID } from "../game/bossCatalog";
import { ALTERNATE_BOSS_CONFIG, BULL_URL, type AlternateBossKind } from "../game/bossVisuals";
import { bossAnimationCue } from "../game/bosses/mechanicPool";
import {
  CAMERA_FOCUS_HEIGHT,
  CAMERA_LOOK_AHEAD,
  CAMERA_ORBIT_DISTANCE,
  DEFAULT_CAMERA_PITCH,
  DEFAULT_CAMERA_YAW,
  setCameraRelativeMovement,
  updateCameraOrbit,
  type CameraOrbitState,
  type PlanarMovement,
} from "../game/cameraOrbit";
import {
  isActorAnimationOneShot,
  shouldStartActorAnimation,
  type ActorAnimationState,
} from "../game/actorAnimation";
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance";
import type { PartyAbilityId } from "../game/partyCombat";
import { partyAttackVfxProfile } from "../game/partyAttackVisuals";
import { useGameStore } from "../game/store";
import type { BossId, MemberId, PulseKind } from "../game/types";
import { BossRoom } from "./BossRoom";
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
import { bossBurrowPositionY, bossIsBurrowing } from "./boss/bossBurrowVisuals";
import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals";
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, selectedGameAssetUrl, useGameGLTF } from "./GameAssetProvider";

const PARTY_MODEL_LEGACY_URLS: Record<MemberId, string> = {
  aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
  brann: new URL("../assets/game/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
  nia: new URL("../assets/game/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
  orin: new URL("../assets/game/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
  vale: new URL("../assets/game/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
};
const PARTY_MODEL_OPTIMIZED_URLS: Record<MemberId, string> = {
  aelia: new URL("../assets/game/models/claudecraft/chars/players/druid-uastc.glb", import.meta.url).href,
  brann: new URL("../assets/game/models/claudecraft/chars/players/knight-uastc.glb", import.meta.url).href,
  nia: new URL("../assets/game/models/claudecraft/chars/players/ranger-uastc.glb", import.meta.url).href,
  orin: new URL("../assets/game/models/claudecraft/chars/players/mage-uastc.glb", import.meta.url).href,
  vale: new URL("../assets/game/models/claudecraft/chars/players/rogue-uastc.glb", import.meta.url).href,
};
const PARTY_MODEL_URLS = Object.fromEntries(Object.keys(PARTY_MODEL_LEGACY_URLS).map((memberId) => [
  memberId,
  selectedGameAssetUrl(
    PARTY_MODEL_LEGACY_URLS[memberId as MemberId],
    PARTY_MODEL_OPTIMIZED_URLS[memberId as MemberId],
  ),
])) as Record<MemberId, string>;
const PARTY_WEAPON_LEGACY_URLS: Record<MemberId, { right: string; left?: string }> = {
  aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
  brann: {
    right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
    left: new URL("../assets/game/models/claudecraft/weapons/shield_badge.glb", import.meta.url).href,
  },
  nia: { right: new URL("../assets/game/models/claudecraft/weapons/crossbow_2handed.glb", import.meta.url).href },
  orin: {
    right: new URL("../assets/game/models/claudecraft/weapons/adv_wand.glb", import.meta.url).href,
    left: new URL("../assets/game/models/claudecraft/weapons/spellbook_open.glb", import.meta.url).href,
  },
  vale: {
    right: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
    left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
  },
};
const PARTY_WEAPON_OPTIMIZED_URLS: Record<MemberId, { right: string; left?: string }> = {
  aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff-uastc.glb", import.meta.url).href },
  brann: {
    right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed-uastc.glb", import.meta.url).href,
    left: new URL("../assets/game/models/claudecraft/weapons/shield_badge-uastc.glb", import.meta.url).href,
  },
  nia: { right: new URL("../assets/game/models/claudecraft/weapons/crossbow_2handed-uastc.glb", import.meta.url).href },
  orin: {
    right: new URL("../assets/game/models/claudecraft/weapons/adv_wand-uastc.glb", import.meta.url).href,
    left: new URL("../assets/game/models/claudecraft/weapons/spellbook_open-uastc.glb", import.meta.url).href,
  },
  vale: {
    right: new URL("../assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb", import.meta.url).href,
    left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb", import.meta.url).href,
  },
};
const PARTY_WEAPON_URLS = Object.fromEntries(Object.keys(PARTY_WEAPON_LEGACY_URLS).map((memberId) => {
  const legacy = PARTY_WEAPON_LEGACY_URLS[memberId as MemberId];
  const optimized = PARTY_WEAPON_OPTIMIZED_URLS[memberId as MemberId];
  return [memberId, {
    right: selectedGameAssetUrl(legacy.right, optimized.right),
    left: legacy.left && optimized.left ? selectedGameAssetUrl(legacy.left, optimized.left) : undefined,
  }];
})) as Record<MemberId, { right: string; left?: string }>;
const PARTY_MODEL_SCALES: Record<MemberId, number> = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 };
const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
  aelia: "2H_Melee_Attack_Chop",
  brann: "1H_Melee_Attack_Chop",
  nia: "2H_Ranged_Shoot",
  orin: "Spellcast_Shoot",
  vale: "Dualwield_Melee_Attack_Chop",
};
const CRITICAL_PARTY_MEMBER_IDS: readonly MemberId[] = ["aelia", "brann"];
const SUPPORT_PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia" | "brann">[] = ["nia", "orin", "vale"];
type GameStoreState = ReturnType<typeof useGameStore.getState>;

interface BossFadeMaterial {
  material: THREE.Material;
  baseOpacity: number;
  baseTransparent: boolean;
  baseDepthWrite: boolean;
}

function createBossRenderModel(source: THREE.Object3D) {
  const model = cloneSkeleton(source);
  const materialClones = new Map<THREE.Material, THREE.Material>();
  model.traverse((object) => {
    if (!(object instanceof THREE.Mesh)) return;
    object.castShadow = true;
    object.receiveShadow = true;
    const cloneMaterial = (material: THREE.Material) => {
      const existing = materialClones.get(material);
      if (existing) return existing;
      const clone = material.clone();
      materialClones.set(material, clone);
      return clone;
    };
    object.material = Array.isArray(object.material)
      ? object.material.map(cloneMaterial)
      : cloneMaterial(object.material);
  });
  model.updateMatrixWorld(true);
  const modelTopY = new THREE.Box3().setFromObject(model).max.y;
  return {
    model,
    modelTopY,
    fadeMaterials: [...materialClones.values()].map((material): BossFadeMaterial => ({
      material,
      baseOpacity: material.opacity,
      baseTransparent: material.transparent,
      baseDepthWrite: material.depthWrite,
    })),
  };
}

function applyBossOpacity(materials: readonly BossFadeMaterial[], opacity: number) {
  const fading = opacity < 0.999;
  for (const entry of materials) {
    const transparent = entry.baseTransparent || fading;
    if (entry.material.transparent !== transparent) {
      entry.material.transparent = transparent;
      entry.material.needsUpdate = true;
    }
    entry.material.opacity = entry.baseOpacity * opacity;
    entry.material.depthWrite = fading ? false : entry.baseDepthWrite;
  }
}

function useBossDeathFade(
  group: RefObject<THREE.Group | null>,
  light: RefObject<THREE.PointLight | null>,
  materials: readonly BossFadeMaterial[],
  defeated: boolean,
  baseLightIntensity: number,
) {
  const elapsed = useRef(0);
  const lastOpacity = useRef(1);
  useFrame((_, delta) => {
    if (!defeated) {
      elapsed.current = 0;
      if (lastOpacity.current !== 1) {
        lastOpacity.current = 1;
        if (group.current) group.current.visible = true;
        if (light.current) light.current.intensity = baseLightIntensity;
        applyBossOpacity(materials, 1);
      }
      return;
    }
    elapsed.current += delta;
    const opacity = bossDeathOpacity(elapsed.current);
    if (opacity === lastOpacity.current) return;
    lastOpacity.current = opacity;
    if (group.current) group.current.visible = opacity > 0;
    if (light.current) light.current.intensity = baseLightIntensity * opacity;
    applyBossOpacity(materials, opacity);
  });
}

function encounterBossAt(state: GameStoreState, bossIndex: number) {
  return bossIndex === 0
    ? { boss: state.boss, motion: state.bossMotion }
    : state.additionalBosses[bossIndex - 1];
}

function targetBossMotion(state: GameStoreState) {
  if (state.boss.hp > 0) return state.bossMotion;
  return state.additionalBosses.find((entry) => entry.boss.hp > 0)?.motion ?? state.bossMotion;
}

function targetBossMotionByInstance(state: GameStoreState, instanceId?: string) {
  if (!instanceId || instanceId === `boss-0-${state.boss.id}`) return targetBossMotion(state);
  return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state);
}

type WeaponGrip = "staff" | "sword" | "crossbow" | "wand" | "dagger" | "prop";

const PARTY_WEAPON_GRIPS: Record<MemberId, { right: WeaponGrip; left?: WeaponGrip }> = {
  aelia: { right: "staff" },
  brann: { right: "sword", left: "prop" },
  nia: { right: "crossbow" },
  orin: { right: "wand", left: "prop" },
  vale: { right: "dagger", left: "dagger" },
};

const VARIANT_GRIPS: Record<Exclude<WeaponGrip, "crossbow" | "prop">, { lift: number; maxHeight: number }> = {
  sword: { lift: 0.04, maxHeight: 2 },
  dagger: { lift: 0.04, maxHeight: 1.4 },
  staff: { lift: 0.18, maxHeight: 2.4 },
  wand: { lift: 0.04, maxHeight: 1.2 },
};

function resolveRigNode(root: THREE.Object3D, authoredName: string) {
  return root.getObjectByName(authoredName)
    ?? root.getObjectByName(authoredName.replace(/[[\].:/]/g, ""));
}

function flattenWeaponScene(scene: THREE.Object3D) {
  if (scene.children.length !== 1) return scene;
  const holder = new THREE.Group();
  const child = scene.children[0];
  holder.scale.copy(child.scale);
  child.position.set(0, 0, 0);
  child.rotation.set(0, 0, 0);
  child.scale.set(1, 1, 1);
  scene.remove(child);
  holder.add(child);
  return holder;
}

function prepareHeldWeapon(scene: THREE.Object3D, grip: WeaponGrip, side: "r" | "l") {
  // Shields and spellbooks carry useful authored offsets, so keep their scene transform.
  if (grip === "prop") return scene;

  const weapon = flattenWeaponScene(scene);
  if (grip === "crossbow") {
    weapon.position.set(0.3381, 0.058, 0);
    weapon.quaternion.set(0, 0.7071068, 0, 0.7071067);
    weapon.scale.setScalar(0.7204);
    return weapon;
  }

  const { lift, maxHeight } = VARIANT_GRIPS[grip];
  const bounds = new THREE.Box3().setFromObject(weapon);
  const height = bounds.max.y - bounds.min.y;
  const scale = height > 0.001 ? Math.min(1, maxHeight / height) : 1;
  weapon.position.set(0, lift, 0);
  weapon.quaternion.set(0, side === "l" ? 0 : 1, 0, side === "l" ? 1 : 0);
  weapon.scale.setScalar(scale);
  return weapon;
}

function PartyCharacterModel({
  memberId,
  animationState,
  animationTrigger,
}: {
  memberId: MemberId;
  animationState: MutableRefObject<ActorAnimationState>;
  animationTrigger: MutableRefObject<number>;
}) {
  const gltf = useGameGLTF(PARTY_MODEL_URLS[memberId]);
  const actorScene = useMemo(() => cloneSkeleton(gltf.scene), [gltf.scene]);
  const loadout = PARTY_WEAPON_URLS[memberId];
  const grips = PARTY_WEAPON_GRIPS[memberId];
  const rightWeapon = useGameGLTF(loadout.right);
  const leftWeapon = useGameGLTF(loadout.left ?? loadout.right);
  const rightHandSlot = resolveRigNode(actorScene, "handslot.r");
  const leftHandSlot = resolveRigNode(actorScene, "handslot.l");
  const rightWeaponScene = useMemo(
    () => prepareHeldWeapon(rightWeapon.scene.clone(true), grips.right, "r"),
    [grips.right, rightWeapon.scene],
  );
  const leftWeaponScene = useMemo(
    () => loadout.left ? prepareHeldWeapon(leftWeapon.scene.clone(true), grips.left ?? grips.right, "l") : null,
    [grips.left, grips.right, leftWeapon.scene, loadout.left],
  );
  const { actions } = useAnimations(gltf.animations, actorScene);
  const activeClip = useRef<string | undefined>(undefined);
  const activeState = useRef<ActorAnimationState | undefined>(undefined);
  const activeTrigger = useRef(Number.NaN);

  useEffect(() => {
    actorScene.traverse((object) => {
      if (object instanceof THREE.Mesh) {
        object.castShadow = true;
        object.receiveShadow = true;
      }
    });
  }, [actorScene]);

  useEffect(() => {
    for (const weaponScene of [rightWeaponScene, leftWeaponScene]) {
      if (!weaponScene) continue;
      weaponScene.traverse((object) => {
        if (object instanceof THREE.Mesh) {
          object.castShadow = true;
          object.receiveShadow = true;
          // Animated hand sockets can cross a mesh's original root-space frustum.
          object.frustumCulled = false;
        }
      });
    }
  }, [leftWeaponScene, rightWeaponScene]);

  useFrame(() => {
    const state = animationState.current;
    const trigger = animationTrigger.current;
    const clipName = state === "death"
      ? "Death_A"
      : state === "hit"
        ? "Hit_A"
        : state === "run"
          ? "Running_A"
          : state === "walk"
            ? "Walking_A"
            : state === "cast"
              ? "Spellcasting"
              : state === "attack"
                ? PARTY_ATTACK_CLIPS[memberId]
                : "Idle";
    if (!shouldStartActorAnimation(activeState.current, activeTrigger.current, state, trigger)) return;
    const next = actions[clipName];
    if (!next) return;
    const clipChanged = activeClip.current !== clipName;
    if (clipChanged && activeClip.current) actions[activeClip.current]?.fadeOut(0.16);
    next.reset().setEffectiveWeight(1).setEffectiveTimeScale(state === "run" ? 1.1 : 1);
    if (clipChanged) next.fadeIn(0.16);
    if (isActorAnimationOneShot(state)) {
      next.setLoop(THREE.LoopOnce, 1);
      next.clampWhenFinished = true;
    } else {
      next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
      next.clampWhenFinished = false;
    }
    next.play();
    activeClip.current = clipName;
    activeState.current = state;
    activeTrigger.current = trigger;
  });

  return (
    <>
      <primitive object={actorScene} scale={PARTY_MODEL_SCALES[memberId]} />
      {rightHandSlot && createPortal(<primitive object={rightWeaponScene} />, rightHandSlot)}
      {leftWeaponScene && leftHandSlot && createPortal(<primitive object={leftWeaponScene} />, leftHandSlot)}
    </>
  );
}

const GAMEPLAY_FRAME_INTERVAL_MS = 1000 / 60;
const BACKGROUND_FRAME_INTERVAL_MS = 1000 / 30;
const FRAME_INTERVAL_JITTER_MS = 1.5;
const SIMULATION_STEP_SECONDS = 0.1;
const MAX_SIMULATION_STEPS_PER_FRAME = 3;
const MIN_RENDER_DPR = 1;
const MAX_RENDER_DPR = 1.25;

/**
 * Owns the only gameplay RAF. R3F stays manually advanced, while simulation remains
 * in the store and is advanced at its existing fixed 10 Hz cadence.
 */
function SceneFrameScheduler({ dpr, onDprChange }: { dpr: number; onDprChange: (next: number) => void }) {
  const { advance } = useThree();
  const gameplayActive = useGameStore((state) => state.phase === "combat" && !state.paused);
  const frameId = useRef<number | null>(null);
  const lastRenderedAt = useRef<number | null>(null);
  const simulationAccumulator = useRef(0);
  const slowFrameMs = useRef(0);
  const stableFrameMs = useRef(0);
  const dprRef = useRef(dpr);
  dprRef.current = dpr;

  useEffect(() => {
    const schedule = (now: number) => {
      const interval = gameplayActive ? GAMEPLAY_FRAME_INTERVAL_MS : BACKGROUND_FRAME_INTERVAL_MS;
      const previous = lastRenderedAt.current;
      if (previous === null) {
        lastRenderedAt.current = now;
        advance(now / 1000, true);
      } else {
        const elapsedMs = now - previous;
        if (elapsedMs + FRAME_INTERVAL_JITTER_MS >= interval) {
          lastRenderedAt.current = now;
          const elapsedSeconds = Math.min(elapsedMs / 1000, 0.25);

          if (gameplayActive) {
            simulationAccumulator.current += elapsedSeconds;
            let steps = 0;
            while (simulationAccumulator.current >= SIMULATION_STEP_SECONDS && steps < MAX_SIMULATION_STEPS_PER_FRAME) {
              const startedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0;
              useGameStore.getState().tick(SIMULATION_STEP_SECONDS);
              if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - startedAt);
              simulationAccumulator.current -= SIMULATION_STEP_SECONDS;
              steps += 1;
            }

            if (elapsedMs > 20) {
              slowFrameMs.current += elapsedMs;
              stableFrameMs.current = 0;
              if (slowFrameMs.current >= 2_000 && dprRef.current > MIN_RENDER_DPR) {
                onDprChange(Math.max(MIN_RENDER_DPR, dprRef.current - 0.125));
                slowFrameMs.current = 0;
              }
            } else {
              slowFrameMs.current = 0;
              stableFrameMs.current += elapsedMs;
              if (stableFrameMs.current >= 10_000 && dprRef.current < MAX_RENDER_DPR) {
                onDprChange(Math.min(MAX_RENDER_DPR, dprRef.current + 0.125));
                stableFrameMs.current = 0;
              }
            }
          } else {
            simulationAccumulator.current = 0;
            slowFrameMs.current = 0;
            stableFrameMs.current = 0;
          }
          advance(now / 1000, true);
        }
      }
      frameId.current = requestAnimationFrame(schedule);
    };

    frameId.current = requestAnimationFrame(schedule);
    return () => {
      if (frameId.current !== null) cancelAnimationFrame(frameId.current);
      frameId.current = null;
      lastRenderedAt.current = null;
      simulationAccumulator.current = 0;
    };
  }, [advance, gameplayActive, onDprChange]);

  return null;
}

function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
  const group = useRef<THREE.Group>(null);
  const animationState = useRef<ActorAnimationState>("idle");
  const animationTrigger = useRef(0);
  useEffect(() => {
    const start = useGameStore.getState().partyPositions[memberId];
    group.current?.position.set(start[0], 0.025, start[1]);
  }, [memberId]);

  useFrame((_, delta) => {
    if (!group.current) return;
    const state = useGameStore.getState();
    const target = state.partyPositions[memberId];
    const dx = target[0] - group.current.position.x;
    const dz = target[1] - group.current.position.z;
    const moving = Math.hypot(dx, dz) > 0.015;
    const movementBlend = 1 - Math.pow(0.002, delta);
    group.current.position.x = THREE.MathUtils.lerp(group.current.position.x, target[0], movementBlend);
    group.current.position.z = THREE.MathUtils.lerp(group.current.position.z, target[1], movementBlend);

    const member = state.party.find((entry) => entry.id === memberId)!;
    const knocked = member.knockedUntil > state.time;
    const visualAction = state.partyCombat.combatants[memberId].visualAction;
    const targetMotion = targetBossMotionByInstance(state, visualAction?.targetInstanceId);
    const attacking = state.phase === "combat"
      && visualAction !== null
      && visualAction.endsAt > state.time;
    animationTrigger.current = member.hp <= 0
      ? 0
      : knocked
        ? member.knockedUntil
        : attacking
          ? visualAction.startedAt
          : 0;
    animationState.current = member.hp <= 0
      ? "death"
      : knocked
        ? "hit"
        : attacking
          ? "attack"
          : moving
            ? memberId === "vale" ? "run" : "walk"
            : "idle";
    if (!knocked && member.hp > 0 && (state.phase === "combat" || moving)) {
      const faceBoss = state.phase === "combat";
      const facingX = faceBoss ? targetMotion.position[0] - group.current.position.x : dx;
      const facingZ = faceBoss ? targetMotion.position[1] - group.current.position.z : dz;
      const targetAngle = Math.atan2(facingX, facingZ);
      const angleDelta = Math.atan2(
        Math.sin(targetAngle - group.current.rotation.y),
        Math.cos(targetAngle - group.current.rotation.y),
      );
      group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
    }
    group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);
  });

  return (
    <group ref={group} rotation={[0, Math.PI, 0]}>
      <PartyCharacterModel memberId={memberId} animationState={animationState} animationTrigger={animationTrigger} />
      {selected && (
        <mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
          <ringGeometry args={[0.55, 0.66, 32]} />
          <meshBasicMaterial color="#f6d477" transparent opacity={0.95} />
        </mesh>
      )}
    </group>
  );
}

function PlayerCharacter() {
  const group = useRef<THREE.Group>(null);
  const animationState = useRef<ActorAnimationState>("idle");
  const animationTrigger = useRef(0);
  const keys = useRef(new Set<string>());
  const scenePulse = useGameStore((state) => state.scenePulse);
  const selected = useGameStore((state) => state.selectedMemberId === "aelia");
  const setPlayerPosition = useGameStore((state) => state.setPlayerPosition);
  const { camera } = useThree();
  const broadcastTimer = useRef(0);
  const castingUntil = useRef(0);
  const instantCastTrigger = useRef(0);
  const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []);
  const cameraOrbit = useRef<CameraOrbitState>({ yaw: DEFAULT_CAMERA_YAW, pitch: DEFAULT_CAMERA_PITCH });
  const cameraRelativeMovement = useRef<PlanarMovement>({ x: 0, z: 0 });

  useEffect(() => {
    const start = useGameStore.getState().partyPositions.aelia;
    group.current?.position.set(start[0], 0.025, start[1]);
  }, []);

  useEffect(() => {
    if (["renew", "shield", "purify", "radiance", "barrier"].includes(scenePulse.kind)) {
      castingUntil.current = performance.now() + 700;
      instantCastTrigger.current = scenePulse.id;
    }
  }, [scenePulse]);

  useEffect(() => {
    const down = (event: KeyboardEvent) => {
      const key = event.key.toLowerCase();
      keys.current.add(key);
      if (event.repeat || !group.current) return;
      const state = useGameStore.getState();
      if (state.phase !== "combat" || state.paused || state.activeCast || state.party[0].hp <= 0 || state.party[0].knockedUntil > state.time) return;
      const nudgeX = Number(key === "d") - Number(key === "a");
      const nudgeZ = Number(key === "s") - Number(key === "w");
      if (!nudgeX && !nudgeZ) return;
      setCameraRelativeMovement(cameraRelativeMovement.current, nudgeX, nudgeZ, cameraOrbit.current.yaw);
      const next = clampToArena([
        group.current.position.x + cameraRelativeMovement.current.x * 0.18,
        group.current.position.z + cameraRelativeMovement.current.z * 0.18,
      ]);
      group.current.position.x = next[0];
      group.current.position.z = next[1];
      setPlayerPosition([group.current.position.x, group.current.position.z]);
    };
    const up = (event: KeyboardEvent) => keys.current.delete(event.key.toLowerCase());
    window.addEventListener("keydown", down);
    window.addEventListener("keyup", up);
    return () => {
      window.removeEventListener("keydown", down);
      window.removeEventListener("keyup", up);
    };
  }, [setPlayerPosition]);

  useFrame((_, delta) => {
    if (!group.current) return;
    let inputX = 0;
    let inputZ = 0;
    const state = useGameStore.getState();
    const knocked = state.party[0].knockedUntil > state.time;
    const player = state.party[0];
    if (state.phase === "combat" && !state.paused && !state.activeCast && player.hp > 0 && !knocked) {
      inputX = Number(keys.current.has("d")) - Number(keys.current.has("a"));
      inputZ = Number(keys.current.has("s")) - Number(keys.current.has("w"));
      const controller = getControllerMovement();
      inputX += controller.moveX;
      inputZ += controller.moveY;
    }
    const controller = getControllerMovement();
    if (state.phase === "combat" && !state.paused) {
      updateCameraOrbit(cameraOrbit.current, controller.lookX, controller.lookY, delta);
    }
    setCameraRelativeMovement(cameraRelativeMovement.current, inputX, inputZ, cameraOrbit.current.yaw);
    inputX = cameraRelativeMovement.current.x;
    inputZ = cameraRelativeMovement.current.z;
    const length = Math.hypot(inputX, inputZ);
    if (length > 0.05) {
      const speed = 4.6 * state.gearModifiers.aelia.moveSpeed * delta / Math.max(1, length);
      const next = clampToArena([group.current.position.x + inputX * speed, group.current.position.z + inputZ * speed]);
      group.current.position.x = next[0];
      group.current.position.z = next[1];
      group.current.rotation.y = Math.atan2(inputX, inputZ);
    } else if (state.phase === "combat" && player.hp > 0 && !knocked) {
      const boss = targetBossMotion(state).position;
      const targetAngle = Math.atan2(boss[0] - group.current.position.x, boss[1] - group.current.position.z);
      const angleDelta = Math.atan2(
        Math.sin(targetAngle - group.current.rotation.y),
        Math.cos(targetAngle - group.current.rotation.y),
      );
      group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
    }
    const instantCasting = performance.now() < castingUntil.current;
    animationTrigger.current = player.hp <= 0
      ? 0
      : knocked
        ? player.knockedUntil
        : state.activeCast
          ? state.activeCast.startedAt
          : instantCasting
            ? instantCastTrigger.current
            : 0;
    animationState.current = player.hp <= 0
      ? "death"
      : knocked
        ? "hit"
        : state.activeCast
          ? "cast"
          : length > 0.05
            ? "run"
            : instantCasting
            ? "cast"
            : "idle";
    group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);

    const horizontalDistance = Math.cos(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE;
    const sinYaw = Math.sin(cameraOrbit.current.yaw);
    const cosYaw = Math.cos(cameraOrbit.current.yaw);
    desiredCameraPosition.set(
      group.current.position.x + sinYaw * horizontalDistance,
      CAMERA_FOCUS_HEIGHT + Math.sin(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE,
      group.current.position.z + cosYaw * horizontalDistance,
    );
    camera.position.lerp(desiredCameraPosition, 1 - Math.pow(0.002, delta));
    camera.lookAt(
      group.current.position.x - sinYaw * CAMERA_LOOK_AHEAD,
      CAMERA_FOCUS_HEIGHT,
      group.current.position.z - cosYaw * CAMERA_LOOK_AHEAD,
    );

    broadcastTimer.current += delta;
    if (broadcastTimer.current > 0.15) {
      setPlayerPosition([group.current.position.x, group.current.position.z]);
      broadcastTimer.current = 0;
    }
  });

  return (
    <group ref={group} rotation={[0, Math.PI, 0]}>
      <PartyCharacterModel memberId="aelia" animationState={animationState} animationTrigger={animationTrigger} />
      {selected && (
        <mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
          <ringGeometry args={[0.55, 0.66, 32]} />
          <meshBasicMaterial color="#f6d477" transparent opacity={0.95} />
        </mesh>
      )}
      <pointLight color="#f7d873" intensity={0.8} distance={2.5} position={[0.45, 1.12, 0]} />
    </group>
  );
}

function Party() {
  const selected = useGameStore((state) => state.selectedMemberId);
  const [loadSupportModels, setLoadSupportModels] = useState(false);

  useEffect(() => {
    const timer = window.setTimeout(() => setLoadSupportModels(true), 750);
    return () => window.clearTimeout(timer);
  }, []);

  return (
    <>
      <Suspense fallback={<PartyFallback memberIds={CRITICAL_PARTY_MEMBER_IDS} />}>
        <PlayerCharacter />
        <Character memberId="brann" selected={selected === "brann"} />
      </Suspense>
      {loadSupportModels ? (
        <Suspense fallback={<PartyFallback memberIds={SUPPORT_PARTY_MEMBER_IDS} />}>
          {SUPPORT_PARTY_MEMBER_IDS.map((memberId) => (
            <Character key={memberId} memberId={memberId} selected={selected === memberId} />
          ))}
        </Suspense>
      ) : <PartyFallback memberIds={SUPPORT_PARTY_MEMBER_IDS} />}
    </>
  );
}

function PartyFallback({ memberIds }: { memberIds: readonly MemberId[] }) {
  const positions = useGameStore((state) => state.partyPositions);
  return (
    <>
      {memberIds.map((memberId) => (
        <mesh key={memberId} castShadow position={[positions[memberId][0], 0.8, positions[memberId][1]]}>
          <capsuleGeometry args={[0.3, 0.75, 4, 8]} />
          <meshStandardMaterial color="#79998c" roughness={0.8} />
        </mesh>
      ))}
    </>
  );
}

function BossFallback({ bossIndex }: { bossIndex: number }) {
  const boss = useGameStore((state) => bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss);
  const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
  const group = useRef<THREE.Group>(null);
  const material = useRef<THREE.MeshStandardMaterial>(null);
  const deathElapsed = useRef(0);
  useFrame((_, delta) => {
    const defeated = (boss?.hp ?? 1) <= 0;
    deathElapsed.current = defeated ? deathElapsed.current + delta : 0;
    const opacity = bossDeathOpacity(deathElapsed.current);
    if (group.current) group.current.visible = opacity > 0;
    if (material.current) {
      const transparent = opacity < 0.999;
      if (material.current.transparent !== transparent) {
        material.current.transparent = transparent;
        material.current.needsUpdate = true;
      }
      material.current.opacity = opacity;
      material.current.depthWrite = opacity >= 0.999;
    }
  });
  if (!boss || !motion) return null;
  const position = motion.position;
  const bossId = boss.id;
  return (
    <group ref={group} position={[position[0], 1.1, position[1]]}>
      <mesh castShadow>
        <dodecahedronGeometry args={[1.1, 0]} />
        <meshStandardMaterial ref={material} color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
      </mesh>
    </group>
  );
}

function BullBoss({ bossIndex }: { bossIndex: number }) {
  const phase = useGameStore((state) => state.phase);
  const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
  const motionMode = motion?.mode ?? "holding";
  const animationCue = motion ? bossAnimationCue(motion) : "idle";
  const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
  const defeated = bossHp <= 0;
  const group = useRef<THREE.Group>(null);
  const light = useRef<THREE.PointLight>(null);
  const gltf = useGLTF(BULL_URL, false, true);
  const { model: bullScene, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
  const { actions } = useAnimations(gltf.animations, bullScene);
  const targetPosition = useMemo(() => new THREE.Vector3(), []);

  useEffect(() => {
    return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
  }, [fadeMaterials]);

  useBossDeathFade(group, light, fadeMaterials, defeated, 2.8);

  const clipName = phase === "victory" || defeated
    ? "Death"
    : animationCue === "attack"
      ? "Idle_Headlow"
      : animationCue === "special"
        ? "Gallop_Jump"
        : animationCue === "move"
        ? "Gallop"
        : "Idle";

  useEffect(() => {
    const next = actions[clipName];
    if (!next) return;
    for (const action of Object.values(actions)) action?.fadeOut(0.18);
    next.reset().setEffectiveWeight(1).setEffectiveTimeScale(motionMode === "charging" ? 1.3 : 1).fadeIn(0.18).play();
    if (clipName === "Death") {
      next.setLoop(THREE.LoopOnce, 1);
      next.clampWhenFinished = true;
    } else {
      next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
    }
    return () => { next.fadeOut(0.18); };
  }, [actions, clipName, motionMode]);

  useFrame((_, delta) => {
    if (!group.current) return;
    const state = useGameStore.getState();
    const current = encounterBossAt(state, bossIndex);
    if (!current) return;
    const motion = current.motion;
    targetPosition.set(motion.position[0], 0.03, motion.position[1]);
    group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
    if (!bossCanTrackTarget(current.boss.hp)) return;

    if (motion.mode === "stacking") {
      group.current.rotation.y += (Math.PI * 2 / 5) * delta;
      return;
    }

    let facingX = state.partyPositions.brann[0] - motion.position[0];
    let facingZ = state.partyPositions.brann[1] - motion.position[1];
    if (motion.mode === "telegraph" || motion.mode === "charging" || motion.mode === "pouncing") {
      facingX = motion.chargeEnd[0] - motion.chargeStart[0];
      facingZ = motion.chargeEnd[1] - motion.chargeStart[1];
    }
    if (Math.hypot(facingX, facingZ) > 0.01) {
      const targetAngle = Math.atan2(facingX, facingZ);
      const difference = Math.atan2(Math.sin(targetAngle - group.current.rotation.y), Math.cos(targetAngle - group.current.rotation.y));
      group.current.rotation.y += difference * (1 - Math.pow(0.001, delta));
    }
  });
  if (phase === "briefing") return null;
  return (
    <group ref={group}>
      <primitive object={bullScene} scale={0.81} />
      <pointLight ref={light} color="#ff9b5c" intensity={2.8} distance={7} position={[0, 2.3, 0.8]} />
    </group>
  );
}


function alternateBossClip(kind: AlternateBossKind, motion: ReturnType<typeof useGameStore.getState>["bossMotion"]) {
  const config = ALTERNATE_BOSS_CONFIG[kind];
  return config[bossAnimationCue(motion)];
}

function AlternateBoss({ kind, bossIndex }: { kind: AlternateBossKind; bossIndex: number }) {
  const config = ALTERNATE_BOSS_CONFIG[kind];
  const archetype = BOSS_ARCHETYPE_BY_ID[kind];
  const phase = useGameStore((state) => state.phase);
  const motion = useGameStore((state) => bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
  const motionMode = motion?.mode ?? "holding";
  const bossHp = useGameStore((state) => (bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
  const defeated = bossHp <= 0;
  const group = useRef<THREE.Group>(null);
  const light = useRef<THREE.PointLight>(null);
  const assetUrl = selectedGameAssetUrl(config.url, config.optimizedUrl ?? config.url);
  const gltf = useGameGLTF(assetUrl);
  const { model, modelTopY, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
  const { actions } = useAnimations(gltf.animations, model);
  const targetPosition = useMemo(() => new THREE.Vector3(), []);
  const burrowPositionY = bossBurrowPositionY(modelTopY, config.scale);

  useEffect(() => {
    return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
  }, [fadeMaterials]);

  useBossDeathFade(group, light, fadeMaterials, defeated, 2.5);

  const clipName = phase === "victory" || defeated ? config.death : alternateBossClip(kind, motion ?? useGameStore.getState().bossMotion);

  useEffect(() => {
    const next = actions[clipName];
    if (!next) return;
    for (const action of Object.values(actions)) action?.fadeOut(0.16);
    const timeScale = archetype === "duelist" && motionMode === "mantis_line_telegraph"
      ? 0.55
      : archetype === "duelist" && motionMode === "mantis_cross_telegraph"
        ? 0.6
        : 1;
    next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale).fadeIn(0.16).play();
    const authoredOneShot = ![config.idle, config.move].includes(clipName);
    if (phase === "victory" || defeated || authoredOneShot) {
      next.setLoop(THREE.LoopOnce, 1);
      next.clampWhenFinished = true;
    } else {
      next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
    }
    return () => { next.fadeOut(0.16); };
  }, [actions, archetype, clipName, config.idle, config.move, defeated, motionMode, phase]);

  useFrame((_, delta) => {
    if (!group.current) return;
    const state = useGameStore.getState();
    const current = encounterBossAt(state, bossIndex);
    if (!current) return;
    const motion = current.motion;
    const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall";
    const burrowing = archetype === "burrower" && bossIsBurrowing(motion.activeMechanicId, motion.mode);
    const floatingHeight = config.floating ? 0.2 : 0.03;
    targetPosition.set(motion.position[0], airborne ? 3.2 : burrowing ? burrowPositionY : floatingHeight, motion.position[1]);
    group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
    if (!bossCanTrackTarget(current.boss.hp)) return;

    let targetAngle = Math.atan2(
      state.partyPositions.brann[0] - motion.position[0],
      state.partyPositions.brann[1] - motion.position[1],
    );
    if (archetype === "sky-sweeper" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
      targetAngle = motion.breathAngle;
    } else if (
      motion.mode === "mantis_line_telegraph"
      || motion.mode === "mantis_cross_telegraph"
    ) {
      const target = state.partyPositions[motion.chargeTargetId];
      targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
    } else if (motion.mode === "telegraph" || motion.mode === "charging") {
      targetAngle = Math.atan2(motion.chargeEnd[0] - motion.position[0], motion.chargeEnd[1] - motion.position[1]);
    }
    const difference = Math.atan2(
      Math.sin(targetAngle - group.current.rotation.y),
      Math.cos(targetAngle - group.current.rotation.y),
    );
    group.current.rotation.y += difference * (1 - Math.pow(0.001, delta));
  });

  if (phase === "briefing") return null;
  return (
    <group ref={group}>
      <primitive object={model} scale={config.scale} rotation={[0, config.rotationOffset, 0]} />
      <pointLight ref={light} color={config.light} intensity={2.5} distance={7} position={[0, 2.2, 0.5]} />
    </group>
  );
}

function BarrierField() {
  const group = useRef<THREE.Group>(null);
  const fill = useRef<THREE.MeshBasicMaterial>(null);
  const innerRing = useRef<THREE.MeshBasicMaterial>(null);

  useFrame(({ clock }) => {
    if (!group.current) return;
    const state = useGameStore.getState();
    const active = state.phase === "combat" && state.barrier.expiresAt > state.time;
    group.current.visible = active;
    if (!active) return;
    group.current.position.set(state.barrier.center[0], 0.058, state.barrier.center[1]);
    group.current.rotation.y = clock.elapsedTime * 0.08;
    if (fill.current) fill.current.opacity = 0.16 + (Math.sin(clock.elapsedTime * 2.6) + 1) * 0.035;
    if (innerRing.current) innerRing.current.opacity = 0.38 + (Math.sin(clock.elapsedTime * 3.2) + 1) * 0.12;
  });

  return (
    <group ref={group} visible={false}>
      <mesh rotation={[-Math.PI / 2, 0, 0]}>
        <circleGeometry args={[3, 64]} />
        <meshBasicMaterial ref={fill} color="#e7bf46" transparent opacity={0.2} depthWrite={false} />
      </mesh>
      <mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
        <ringGeometry args={[2.92, 3.05, 64]} />
        <meshBasicMaterial color="#ffd968" transparent opacity={0.88} depthWrite={false} />
      </mesh>
      <mesh position={[0, 0.016, 0]} rotation={[-Math.PI / 2, 0, 0]}>
        <ringGeometry args={[1.82, 1.9, 48]} />
        <meshBasicMaterial ref={innerRing} color="#ffe89a" transparent opacity={0.55} depthWrite={false} />
      </mesh>
      {Array.from({ length: 8 }, (_, index) => {
        const angle = (index / 8) * Math.PI * 2;
        return (
          <mesh
            key={index}
            position={[Math.sin(angle) * 2.35, 0.02, Math.cos(angle) * 2.35]}
            rotation={[-Math.PI / 2, 0, angle]}
          >
            <ringGeometry args={[0.09, 0.16, 6]} />
            <meshBasicMaterial color="#ffe89a" transparent opacity={0.62} depthWrite={false} />
          </mesh>
        );
      })}
    </group>
  );
}

function TankAuraField() {
  const group = useRef<THREE.Group>(null);
  const material = useRef<THREE.MeshBasicMaterial>(null);
  useFrame(({ clock }) => {
    if (!group.current) return;
    const state = useGameStore.getState();
    const active = state.phase === "combat" && state.partyCombat.tankAura.expiresAt > state.time && state.party[1].hp > 0;
    group.current.visible = active;
    if (!active) return;
    const brann = state.partyPositions.brann;
    group.current.position.set(brann[0], 0.06, brann[1]);
    group.current.rotation.y = clock.elapsedTime * -0.22;
    if (material.current) material.current.opacity = 0.13 + (Math.sin(clock.elapsedTime * 5) + 1) * 0.05;
  });
  return (
    <group ref={group} visible={false}>
      <mesh rotation={[-Math.PI / 2, 0, 0]}>
        <circleGeometry args={[3, 48]} />
        <meshBasicMaterial ref={material} color="#67bfff" transparent opacity={0.18} depthWrite={false} />
      </mesh>
      <mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
        <ringGeometry args={[2.88, 3.04, 48]} />
        <meshBasicMaterial color="#8fd5ff" transparent opacity={0.92} depthWrite={false} />
      </mesh>
      {Array.from({ length: 6 }, (_, index) => {
        const angle = (index / 6) * Math.PI * 2;
        return <mesh key={index} position={[Math.sin(angle) * 2.35, 0.025, Math.cos(angle) * 2.35]} rotation={[-Math.PI / 2, 0, angle]}>
          <ringGeometry args={[0.08, 0.15, 4]} />
          <meshBasicMaterial color="#d2efff" transparent opacity={0.8} depthWrite={false} />
        </mesh>;
      })}
    </group>
  );
}

function RangedProjectile({ memberId }: { memberId: "nia" | "orin" }) {
  const projectile = useRef<THREE.Group>(null);
  const impact = useRef<THREE.Group>(null);
  const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const accentMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const trailMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const trail = useRef<THREE.Mesh>(null);
  const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const impactCoreMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const lastAbilityId = useRef<PartyAbilityId | null>(null);
  const start = useMemo(() => new THREE.Vector3(), []);
  const end = useMemo(() => new THREE.Vector3(), []);
  const current = useMemo(() => new THREE.Vector3(), []);
  const direction = useMemo(() => new THREE.Vector3(), []);
  const up = useMemo(() => new THREE.Vector3(0, 1, 0), []);

  useFrame(({ clock }) => {
    if (!projectile.current || !impact.current) return;
    const state = useGameStore.getState();
    const action = state.partyCombat.combatants[memberId].visualAction;
    const member = state.party.find((entry) => entry.id === memberId)!;
    const rapid = action?.abilityId === "rapid_fire";
    const active = action !== null
      && state.phase === "combat"
      && member.hp > 0
      && action.abilityId !== "overcharge"
      && state.time >= action.startedAt
      && (rapid ? state.time <= action.endsAt : state.time <= action.impactAt);
    projectile.current.visible = active;
    impact.current.visible = false;
    if (!action || state.phase !== "combat" || member.hp <= 0 || action.abilityId === "overcharge") return;

    const profile = partyAttackVfxProfile(action.abilityId);
    if (lastAbilityId.current !== action.abilityId) {
      lastAbilityId.current = action.abilityId;
      coreMaterial.current?.color.set(profile.primary);
      accentMaterial.current?.color.set(profile.accent);
      trailMaterial.current?.color.set(profile.primary);
      impactMaterial.current?.color.set(profile.accent);
      impactCoreMaterial.current?.color.set(profile.primary);
    }

    const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
    const projectileDuration = Math.max(0.12, action.impactAt - action.startedAt);
    const progress = rapid
      ? ((state.time - action.startedAt) % 0.4) / 0.4
      : Math.min(1, (state.time - action.startedAt) / projectileDuration);
    const source = state.partyPositions[memberId];
    const target = targetMotion.position;
    start.set(source[0], 1.18, source[1]);
    end.set(target[0], 1.12, target[1]);
    if (active) {
      current.copy(start).lerp(end, progress);
      current.y += Math.sin(progress * Math.PI) * (memberId === "orin" ? 0.95 : 0.34);
      projectile.current.position.copy(current);
      direction.subVectors(end, start).normalize();
      projectile.current.quaternion.setFromUnitVectors(up, direction);
      const pulseScale = memberId === "orin" ? 1 + Math.sin(clock.elapsedTime * 14) * 0.12 : 1;
      projectile.current.scale.setScalar(profile.scale * pulseScale);
      trail.current?.scale.set(1, profile.trail, 1);
      if (trailMaterial.current) trailMaterial.current.opacity = 0.34 + Math.sin(clock.elapsedTime * 10) * 0.08;
    }

    const impactProgress = rapid
      ? progress > 0.72 ? (progress - 0.72) / 0.28 : -1
      : (state.time - action.impactAt) / 0.3;
    const impactVisible = impactProgress >= 0 && impactProgress <= 1 && state.time <= action.endsAt + 0.3;
    impact.current.visible = impactVisible;
    if (impactVisible) {
      impact.current.position.copy(end);
      impact.current.scale.setScalar(profile.scale * (0.45 + impactProgress * 2.15));
      if (impactMaterial.current) impactMaterial.current.opacity = (1 - impactProgress) * 0.9;
      if (impactCoreMaterial.current) impactCoreMaterial.current.opacity = (1 - impactProgress) * 0.72;
    }
  });

  return (
    <>
      <group ref={projectile} visible={false}>
        {memberId === "nia" ? (
          <>
            <mesh>
              <cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
              <meshBasicMaterial ref={coreMaterial} color="#77d596" />
            </mesh>
            <mesh position={[0, 0.5, 0]}>
              <coneGeometry args={[0.085, 0.2, 6]} />
              <meshBasicMaterial ref={accentMaterial} color="#e5ffb8" />
            </mesh>
            <mesh position={[0, -0.4, 0]}>
              <coneGeometry args={[0.1, 0.18, 4]} />
              <meshBasicMaterial color="#d7b477" />
            </mesh>
            <mesh ref={trail} position={[0, -0.62, 0]}>
              <coneGeometry args={[0.12, 0.72, 6, 1, true]} />
              <meshBasicMaterial ref={trailMaterial} color="#77d596" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
            </mesh>
          </>
        ) : (
          <>
            <mesh>
              <sphereGeometry args={[0.19, 12, 10]} />
              <meshBasicMaterial ref={coreMaterial} color="#a87cff" toneMapped={false} />
            </mesh>
            <mesh rotation={[Math.PI / 2, 0, 0]}>
              <torusGeometry args={[0.25, 0.025, 6, 20]} />
              <meshBasicMaterial ref={accentMaterial} color="#ead9ff" transparent opacity={0.8} depthWrite={false} />
            </mesh>
            <mesh ref={trail} position={[0, -0.48, 0]}>
              <coneGeometry args={[0.18, 0.95, 8, 1, true]} />
              <meshBasicMaterial ref={trailMaterial} color="#a87cff" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
            </mesh>
          </>
        )}
      </group>
      <group ref={impact} visible={false}>
        <mesh rotation={[-Math.PI / 2, 0, 0]}>
          <ringGeometry args={[0.28, 0.45, 24]} />
          <meshBasicMaterial ref={impactMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
        </mesh>
        <mesh>
          <octahedronGeometry args={[0.24, 0]} />
          <meshBasicMaterial ref={impactCoreMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
        </mesh>
      </group>
    </>
  );
}

function RangedProjectiles() {
  return (
    <>
      <RangedProjectile memberId="nia" />
      <RangedProjectile memberId="orin" />
    </>
  );
}

function CloseAttackVfx({ memberId }: { memberId: "brann" | "orin" | "vale" }) {
  const group = useRef<THREE.Group>(null);
  const firstArc = useRef<THREE.Mesh>(null);
  const secondArc = useRef<THREE.Mesh>(null);
  const groundRing = useRef<THREE.Mesh>(null);
  const core = useRef<THREE.Mesh>(null);
  const primaryMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const secondaryMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const ringMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const lastAbilityId = useRef<PartyAbilityId | null>(null);

  useFrame(({ clock }) => {
    if (!group.current || !firstArc.current || !secondArc.current || !groundRing.current || !core.current) return;
    const state = useGameStore.getState();
    const actor = state.partyCombat.combatants[memberId];
    const action = actor.visualAction;
    const member = state.party.find((entry) => entry.id === memberId)!;
    const visible = action !== null
      && state.phase === "combat"
      && member.hp > 0
      && state.time >= action.startedAt
      && state.time <= action.endsAt + 0.18
      && partyAttackVfxProfile(action.abilityId).style !== "projectile";
    group.current.visible = visible;
    if (!visible || !action) return;

    const profile = partyAttackVfxProfile(action.abilityId);
    if (lastAbilityId.current !== action.abilityId) {
      lastAbilityId.current = action.abilityId;
      primaryMaterial.current?.color.set(profile.primary);
      secondaryMaterial.current?.color.set(profile.accent);
      ringMaterial.current?.color.set(profile.primary);
      coreMaterial.current?.color.set(profile.accent);
    }

    const duration = Math.max(0.2, action.endsAt - action.startedAt);
    const progress = THREE.MathUtils.clamp((state.time - action.startedAt) / duration, 0, 1);
    const impactProgress = THREE.MathUtils.clamp((state.time - action.impactAt + 0.08) / 0.3, 0, 1);
    const source = state.partyPositions[memberId];
    const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
    const target = targetMotion.position;
    const sourceStyle = profile.style === "buff" || profile.style === "spin";
    const effectHeight = sourceStyle ? 0.22 : profile.style === "slam" ? 0.18 : 1.05;
    group.current.position.set(sourceStyle ? source[0] : target[0], effectHeight, sourceStyle ? source[1] : target[1]);
    group.current.rotation.y = sourceStyle
      ? clock.elapsedTime * 0.8
      : Math.atan2(target[0] - source[0], target[1] - source[1]);

    const slashStyle = profile.style === "slash" || profile.style === "double-slash";
    firstArc.current.visible = slashStyle;
    secondArc.current.visible = profile.style === "double-slash";
    groundRing.current.visible = profile.style === "slam" || profile.style === "spin" || profile.style === "buff";
    core.current.visible = profile.style === "slam" || profile.style === "buff";
    const actionScale = profile.scale * (0.65 + Math.sin(progress * Math.PI) * 0.75);
    firstArc.current.scale.setScalar(actionScale);
    firstArc.current.rotation.z = -Math.PI * (0.82 - progress * 0.34);
    secondArc.current.scale.setScalar(actionScale * 0.92);
    secondArc.current.rotation.z = -Math.PI * (0.15 + progress * 0.36);
    const burstScale = profile.scale * (0.5 + impactProgress * 2.2);
    groundRing.current.scale.setScalar(burstScale);
    groundRing.current.rotation.z = clock.elapsedTime * (memberId === "vale" ? -1.6 : 0.85);
    core.current.scale.setScalar(profile.scale * (0.6 + Math.sin(progress * Math.PI) * 1.1));
    core.current.rotation.set(clock.elapsedTime, clock.elapsedTime * 1.4, 0);
    if (primaryMaterial.current) primaryMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.9;
    if (secondaryMaterial.current) secondaryMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.84;
    if (ringMaterial.current) ringMaterial.current.opacity = (1 - impactProgress * 0.7) * 0.76;
    if (coreMaterial.current) coreMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.72;
  });

  return (
    <group ref={group} visible={false}>
      <mesh ref={firstArc} rotation={[0, 0, -Math.PI * 0.72]}>
        <torusGeometry args={[0.72, 0.055, 6, 28, Math.PI * 1.28]} />
        <meshBasicMaterial ref={primaryMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
      <mesh ref={secondArc} position={[0.16, 0.02, 0.05]} rotation={[0, 0, -Math.PI * 0.2]}>
        <torusGeometry args={[0.64, 0.045, 6, 26, Math.PI * 1.18]} />
        <meshBasicMaterial ref={secondaryMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
      <mesh ref={groundRing} rotation={[-Math.PI / 2, 0, 0]}>
        <ringGeometry args={[0.38, 0.54, 28]} />
        <meshBasicMaterial ref={ringMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
      <mesh ref={core} position={[0, 0.3, 0]}>
        <octahedronGeometry args={[0.26, 0]} />
        <meshBasicMaterial ref={coreMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
    </group>
  );
}

function PartyPowerAuraVfx({ memberId }: { memberId: "orin" | "vale" }) {
  const group = useRef<THREE.Group>(null);
  const material = useRef<THREE.MeshBasicMaterial>(null);
  useFrame(({ clock }) => {
    if (!group.current) return;
    const state = useGameStore.getState();
    const actor = state.partyCombat.combatants[memberId];
    const member = state.party.find((entry) => entry.id === memberId)!;
    const active = state.phase === "combat" && member.hp > 0 && (memberId === "orin" ? actor.overchargeStacks > 0 : actor.bladeFlurryUntil > state.time);
    group.current.visible = active;
    if (!active) return;
    const position = state.partyPositions[memberId];
    group.current.position.set(position[0], 0.16, position[1]);
    group.current.rotation.y = clock.elapsedTime * (memberId === "orin" ? 1.4 : -1.8);
    const pulse = 0.92 + Math.sin(clock.elapsedTime * 5.5) * 0.12;
    group.current.scale.setScalar(pulse);
    if (material.current) material.current.opacity = 0.38 + Math.sin(clock.elapsedTime * 4.2) * 0.1;
  });
  const color = memberId === "orin" ? "#bc72ff" : "#9a86ff";
  return (
    <group ref={group} visible={false}>
      <mesh rotation={[-Math.PI / 2, 0, 0]}>
        <torusGeometry args={[0.7, 0.035, 6, 28]} />
        <meshBasicMaterial ref={material} color={color} transparent opacity={0.4} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
      <mesh position={[0, 0.72, 0]} rotation={[Math.PI / 2, 0, 0]}>
        <torusGeometry args={[0.46, 0.025, 6, 24]} />
        <meshBasicMaterial color={color} transparent opacity={0.5} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
    </group>
  );
}

function PartyCombatVfx() {
  return (
    <>
      <RangedProjectiles />
      <CloseAttackVfx memberId="brann" />
      <CloseAttackVfx memberId="orin" />
      <CloseAttackVfx memberId="vale" />
      <PartyPowerAuraVfx memberId="orin" />
      <PartyPowerAuraVfx memberId="vale" />
    </>
  );
}

function BossActor() {
  const phase = useGameStore((state) => state.phase);
  const primaryBossId = useGameStore((state) => state.boss.id);
  const additionalBossIds = useGameStore((state) => state.additionalBosses.map((entry) => entry.boss.id).join("|"));
  if (phase === "briefing") return null;
  const bossIds = additionalBossIds ? [primaryBossId, ...additionalBossIds.split("|")] : [primaryBossId];
  return (
    <>{bossIds.map((bossId, bossIndex) => (
      <Suspense key={`${bossId}-${bossIndex}`} fallback={<BossFallback bossIndex={bossIndex} />}>
        {bossId === "bulldrome" ? <BullBoss bossIndex={bossIndex} /> : <AlternateBoss kind={bossId as AlternateBossKind} bossIndex={bossIndex} />}
      </Suspense>
    ))}</>
  );
}

type PerformanceMemory = Performance & {
  memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number };
};

function percentile(sorted: readonly number[], ratio: number) {
  if (!sorted.length) return 0;
  return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * ratio))];
}

function PerformanceProbe() {
  const { gl } = useThree();
  const frameSamples = useRef<number[]>([]);
  const longTaskCount = useRef(0);
  const longTaskDuration = useRef(0);
  const lastPublishAt = useRef(0);

  useEffect(() => {
    if (!PERFORMANCE_PROBE_ENABLED || typeof PerformanceObserver === "undefined") return;
    let observer: PerformanceObserver | undefined;
    try {
      observer = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          longTaskCount.current += 1;
          longTaskDuration.current += entry.duration;
        }
      });
      observer.observe({ type: "longtask", buffered: true });
    } catch {
      // Long Tasks API is optional on Android WebView implementations.
    }
    return () => {
      observer?.disconnect();
      delete document.documentElement.dataset.gamePerf;
    };
  }, []);

  useFrame(({ clock }, delta) => {
    if (!PERFORMANCE_PROBE_ENABLED) return;
    const samples = frameSamples.current;
    if (samples.length === 300) samples.shift();
    samples.push(delta * 1000);
    if (clock.elapsedTime - lastPublishAt.current < 1 || samples.length < 30) return;
    lastPublishAt.current = clock.elapsedTime;
    const sorted = [...samples].sort((left, right) => left - right);
    let total = 0;
    let overBudget = 0;
    for (const duration of samples) {
      total += duration;
      if (duration > 16.67) overBudget += 1;
    }
    const memory = performance as PerformanceMemory;
    const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
    let transferredBytes = 0;
    let decodedBytes = 0;
    for (const resource of resources) {
      transferredBytes += resource.transferSize;
      decodedBytes += resource.decodedBodySize;
    }
    document.documentElement.dataset.gamePerf = JSON.stringify({
      frame: {
        targetFps: 60,
        budgetMs: 16.67,
        averageMs: total / samples.length,
        p95Ms: percentile(sorted, 0.95),
        p99Ms: percentile(sorted, 0.99),
        overBudget,
        samples: samples.length,
      },
      renderer: {
        calls: gl.info.render.calls,
        triangles: gl.info.render.triangles,
        geometries: gl.info.memory.geometries,
        textures: gl.info.memory.textures,
        pixelRatio: gl.getPixelRatio(),
        renderWidth: gl.domElement.width,
        renderHeight: gl.domElement.height,
      },
      simulation: simulationTickSnapshot(),
      memory: memory.memory ? {
        usedJSHeapSize: memory.memory.usedJSHeapSize,
        totalJSHeapSize: memory.memory.totalJSHeapSize,
        jsHeapSizeLimit: memory.memory.jsHeapSizeLimit,
      } : null,
      resources: { transferredBytes, decodedBytes, count: resources.length },
      longTasks: { count: longTaskCount.current, durationMs: longTaskDuration.current },
    });
  });
  return null;
}

const FX_BURST_PARTICLE_COUNT = 8;

function scenePulseColor(kind: PulseKind) {
  if (kind === "shield") return "#62bdff";
  if (kind === "purify") return "#c39bff";
  if (kind === "renew") return "#72e0a1";
  if (kind === "breath") return "#66dcff";
  if (kind === "venom") return "#8fdb4f";
  if (kind === "tether") return "#d482ff";
  if (kind === "skyfall") return "#ffd36b";
  if (kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash") return "#ff643c";
  return "#ffe087";
}

function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
  const group = useRef<THREE.Group>(null);
  const ring = useRef<THREE.Mesh>(null);
  const material = useRef<THREE.MeshBasicMaterial>(null);
  const particles = useRef<THREE.InstancedMesh>(null);
  const particleMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const core = useRef<THREE.Mesh>(null);
  const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const transform = useMemo(() => new THREE.Object3D(), []);
  const age = useRef(0);
  const isBossFx = kind === "boss";
  const state = useGameStore.getState();
  const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
  const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
  const color = scenePulseColor(kind);
  useFrame(({ clock }, delta) => {
    age.current += delta;
    if (!group.current || !ring.current || !material.current || !particles.current || !core.current) return;
    const progress = Math.min(1, age.current / 0.7);
    ring.current.scale.setScalar(0.5 + progress * 3.6);
    material.current.opacity = (1 - progress) * 0.85;
    core.current.scale.setScalar(0.45 + Math.sin(progress * Math.PI) * 1.8);
    core.current.rotation.set(clock.elapsedTime * 1.5, clock.elapsedTime * 2, 0);
    if (coreMaterial.current) coreMaterial.current.opacity = (1 - progress) * 0.72;
    for (let index = 0; index < FX_BURST_PARTICLE_COUNT; index += 1) {
      const angle = index / FX_BURST_PARTICLE_COUNT * Math.PI * 2 + clock.elapsedTime * 0.6;
      const radial = progress * (kind === "boss" ? 3.2 : 1.65);
      const scale = (1 - progress) * (kind === "boss" ? 1.4 : 0.9);
      transform.position.set(Math.sin(angle) * radial, 0.18 + Math.sin(progress * Math.PI) * (0.7 + (index % 2) * 0.45), Math.cos(angle) * radial);
      transform.rotation.set(angle, progress * Math.PI * 2 + index, clock.elapsedTime);
      transform.scale.setScalar(scale);
      transform.updateMatrix();
      particles.current.setMatrixAt(index, transform.matrix);
    }
    particles.current.instanceMatrix.needsUpdate = true;
    if (particleMaterial.current) particleMaterial.current.opacity = (1 - progress) * 0.82;
  });
  return (
    <group ref={group} position={position}>
      <mesh ref={ring} rotation={[-Math.PI / 2, 0, 0]}>
        <ringGeometry args={[0.38, 0.5, 32]} />
        <meshBasicMaterial ref={material} color={color} transparent depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
      <mesh ref={core} position={[0, 0.42, 0]}>
        <octahedronGeometry args={[0.24, 0]} />
        <meshBasicMaterial ref={coreMaterial} color={color} transparent opacity={0.72} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
      <instancedMesh ref={particles} args={[undefined, undefined, FX_BURST_PARTICLE_COUNT]} frustumCulled={false}>
        <tetrahedronGeometry args={[0.18, 0]} />
        <meshBasicMaterial ref={particleMaterial} color={color} transparent opacity={0.82} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </instancedMesh>
    </group>
  );
}

function CombatFx() {
  const pulse = useGameStore((state) => state.scenePulse);
  if (!pulse.id) return null;
  return <FxBurst key={pulse.id} kind={pulse.kind} targetId={pulse.targetId} />;
}

export function GameScene() {
  const [dpr, setDpr] = useState(() => Math.min(MAX_RENDER_DPR, Math.max(MIN_RENDER_DPR, window.devicePixelRatio || 1)));
  const setRenderDpr = useCallback((next: number) => {
    setDpr((current) => Math.abs(current - next) < 0.001 ? current : next);
  }, []);

  return (
    <Canvas
      frameloop="never"
      shadows="basic"
      dpr={dpr}
      camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
      gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
    >
      <GameAssetProvider>
        <SceneFrameScheduler dpr={dpr} onDprChange={setRenderDpr} />
        <BossRoom />
        <BossMechanicIndicators />
        <BarrierField />
        <TankAuraField />
        <Party />
        <BossActor />
        <PartyCombatVfx />
        <CombatFx />
        {PERFORMANCE_PROBE_ENABLED && <PerformanceProbe />}
      </GameAssetProvider>
    </Canvas>
  );
}

if (LEGACY_GAME_ASSETS_FORCED) {
  for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
    useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
    const loadout = PARTY_WEAPON_URLS[memberId];
    useGLTF.preload(loadout.right, false, true);
    if (loadout.left) useGLTF.preload(loadout.left, false, true);
  }
}
