import { type PointerEvent, useEffect, useMemo, useRef, useState } from 'react';

import type {
  Match3DItemSnapshot,
  Match3DRunSnapshot,
} from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
import { isDebugMode } from '../../config/debugMode';
import {
  readMatch3DGeneratedModelBytes,
  resolveMatch3DGeneratedModelAssetSource,
} from '../../services/match3dGeneratedModelCache';
import {
  isItemState,
  resolveRenderableItemFrame,
} from './match3dRuntimePresentation';
import {
  type Match3DGeometryAsset,
  type Match3DGeometryShape,
  resolveGeometryAsset,
} from './match3dVisualAssets';

type Match3DPhysicsBoardProps = {
  run: Match3DRunSnapshot;
  generatedItemAssets?: Match3DGeneratedItemAsset[];
  disabled: boolean;
  onClickItem: (item: Match3DItemSnapshot) => void;
  onFallback: () => void;
};

type Match3DPhysicsPointerSelection = {
  itemInstanceId: string | null;
  pointerId: number;
};

type ThreeModule = typeof import('three');
type CannonModule = typeof import('cannon-es');
type PhysicsBody = import('cannon-es').Body;
type CannonShape = import('cannon-es').Shape;
type PhysicsWorld = import('cannon-es').World;
type ThreeObject3D = import('three').Object3D;
type ThreeScene = import('three').Scene;
type ThreeRenderer = import('three').WebGLRenderer;
type ThreeCamera = import('three').OrthographicCamera;
type Match3DGeneratedModelTemplate = {
  source: string;
  scene: ThreeObject3D;
};
type Match3DGeneratedModelTemplateMap = Map<
  string,
  Match3DGeneratedModelTemplate
>;

type PhysicsEntry = {
  boundaryRadius: number;
  colliderHeight: number;
  item: Match3DItemSnapshot;
  baseVisualScale: import('three').Vector3;
  body: PhysicsBody;
  lockReadableTop: boolean;
  mesh: ThreeObject3D;
  renderSignature: string;
  spawnStartedAt: number;
  targetY: number;
  topRotationY: number;
};

type PendingPhysicsSpawn = {
  activeLayerRank: number;
  item: Match3DItemSnapshot;
  renderSignature: string;
  spawnAtMs: number;
  layerCapacity: number;
  targetY: number;
};

type StackHeightTarget = {
  activeLayerRank: number;
  targetY: number;
};

type BoardDepthPlan = {
  activeDepth: number;
  activeItemCount: number;
  baseY: number;
  initialDepth: number;
  layerCapacity: number;
  layerCount: number;
  layerStep: number;
  maxVerticalSpeed: number;
  surfaceY: number;
};

type PhysicsStabilityPlan = {
  angularDamping: number;
  contactFriction: number;
  contactRestitution: number;
  linearDamping: number;
  maxHorizontalSpeed: number;
  maxVerticalSpeed: number;
  solverIterations: number;
  solverTolerance: number;
};

type Match3DSpawnTimingPlan = {
  frameSpawnLimit: number;
  initialDelayMs: number;
  layerDelayMs: number;
  burstSize: number;
  staggerMs: number;
};

type Match3DSpawnHeightObstacle = {
  boundaryRadius: number;
  colliderHeight: number;
  x: number;
  y: number;
  z: number;
};

type PhysicsRuntime = {
  animationId: number | null;
  camera: ThreeCamera;
  entries: Map<string, PhysicsEntry>;
  failedGeneratedModelTypeIds: Set<string>;
  generatedModelByType: Map<string, Match3DGeneratedItemAsset>;
  generatedModelTemplates: Match3DGeneratedModelTemplateMap;
  pendingSpawns: Map<string, PendingPhysicsSpawn>;
  raycaster: import('three').Raycaster;
  renderer: ThreeRenderer;
  scene: ThreeScene;
  spawnTimingPlan: Match3DSpawnTimingPlan;
  stabilityPlan: PhysicsStabilityPlan;
  world: PhysicsWorld;
  three: ThreeModule;
  cannon: CannonModule;
};

type Match3DStackHeightPlan = {
  layerCapacity: number;
  targets: Map<string, StackHeightTarget>;
};

const MATCH3D_POT_FLOOR_RADIUS = 4.75;
const MATCH3D_POT_INNER_RADIUS = 4.52;
const MATCH3D_POT_OUTER_RADIUS = 5.18;
const MATCH3D_POT_WALL_HEIGHT = 2.15;
const MATCH3D_ITEM_ACTIVITY_RADIUS = 3.58;
const MATCH3D_ITEM_POSITION_RADIUS = 3.34;
const MATCH3D_ITEM_BASE_HEIGHT = 1.18;
const MATCH3D_ITEM_VERTICAL_DEPTH_BASE = 2.8;
const MATCH3D_ITEM_VERTICAL_DEPTH_SQRT_SCALE = 0.52;
const MATCH3D_ITEM_VERTICAL_DEPTH_COUNT_SCALE = 0.032;
const MATCH3D_ITEM_VERTICAL_DEPTH_MAX_BASE = 12;
const MATCH3D_ITEM_VERTICAL_DEPTH_MAX_COUNT_SCALE = 0.04;
const MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE = 18;
const MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_MIN = 10;
const MATCH3D_ITEM_VERTICAL_LAYER_STEP_MAX = 1.04;
const MATCH3D_ITEM_LIFT_FORCE_SCALE = 18;
const MATCH3D_ITEM_LIFT_MAX_SPEED = 4.2;
const MATCH3D_ITEM_SPAWN_RISE_OFFSET = 0.42;
const MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS = 54;
const MATCH3D_ITEM_SPAWN_STAGGER_MS = 4;
const MATCH3D_ITEM_SPAWN_STACK_CLEARANCE = 0.14;
const MATCH3D_ITEM_SPAWN_STACK_RADIUS_PADDING = 0.08;
const MATCH3D_ITEM_SPAWN_ANIMATION_MS = 260;
const MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START = 0.18;
const MATCH3D_ITEM_SPAWN_VISUAL_DROP_OFFSET = 0.04;
const MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT = 8.6;
const MATCH3D_ITEM_EXTREME_HORIZONTAL_SPEED_LIMIT = 4.4;
const MATCH3D_CENTER_GRAVITY_COEFFICIENT = 0;
const MATCH3D_BOARD_CENTER = 0.5;
const MATCH3D_PHYSICS_STEP = 1 / 60;
const MATCH3D_CAMERA_HALF_SIZE = 6.15;
const MATCH3D_GENERATED_MODEL_TARGET_RADIUS_SCALE = 1.9;
const MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT = 20;
const MATCH3D_SELECTED_MODEL_SCALE = 1.1;
export const MATCH3D_EXTRUDED_READABLE_SHAPES: ReadonlySet<Match3DGeometryShape> =
  new Set(['ring', 'arch']);

function normalizeMatch3DGeneratedModelSource(
  asset: Match3DGeneratedItemAsset,
) {
  return resolveMatch3DGeneratedModelAssetSource(asset);
}

function compareMatch3DGeneratedTypeId(left: string, right: string) {
  const leftIndex = Number.parseInt(left.match(/(\d+)$/u)?.[1] ?? '', 10);
  const rightIndex = Number.parseInt(right.match(/(\d+)$/u)?.[1] ?? '', 10);
  if (Number.isFinite(leftIndex) && Number.isFinite(rightIndex)) {
    return leftIndex - rightIndex;
  }
  return left.localeCompare(right);
}

function resolveMatch3DGeneratedModelTypeIds(items: Match3DItemSnapshot[]) {
  return [
    ...new Set(items.map((item) => item.itemTypeId.trim()).filter(Boolean)),
  ].sort(compareMatch3DGeneratedTypeId);
}

export function buildMatch3DGeneratedAssetTypeMap(
  run: Match3DRunSnapshot,
  generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
) {
  const typeIds = resolveMatch3DGeneratedModelTypeIds(run.items);
  const readyAssets = generatedItemAssets
    .map((asset) => ({
      asset,
      source: normalizeMatch3DGeneratedModelSource(asset),
    }))
    .filter(({ source }) => Boolean(source))
    .slice(0, MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT);
  const assetMap = new Map<string, Match3DGeneratedItemAsset>();

  typeIds.forEach((itemTypeId, index) => {
    const resolved = readyAssets[index];
    if (!resolved) {
      return;
    }
    assetMap.set(itemTypeId, {
      ...resolved.asset,
      modelSrc: resolved.source,
    });
    debugMatch3DGeneratedModelMapped(itemTypeId, resolved.source);
  });

  return assetMap;
}

function buildGeneratedModelMapSignature(
  generatedModelByType: Map<string, Match3DGeneratedItemAsset>,
) {
  return [...generatedModelByType.entries()]
    .map(
      ([itemTypeId, asset]) =>
        `${itemTypeId}:${normalizeMatch3DGeneratedModelSource(asset)}`,
    )
    .join('|');
}

function resolveGeneratedModelSourceForItemType(
  generatedModelByType: Map<string, Match3DGeneratedItemAsset>,
  itemTypeId: string,
) {
  const asset = generatedModelByType.get(itemTypeId);
  return asset ? normalizeMatch3DGeneratedModelSource(asset) : '';
}

function shouldLogMatch3DGeneratedModelDiagnostics() {
  return isDebugMode() && import.meta.env.MODE !== 'test';
}

function warnMatch3DGeneratedModelLoadFailure(
  itemTypeId: string,
  source: string,
  error: unknown,
) {
  if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
    return;
  }
  const message =
    error instanceof Error ? error.message : String(error || 'unknown error');
  console.warn('[match3d] generated model load failed', {
    itemTypeId,
    source,
    message,
  });
}

function debugMatch3DGeneratedModelLoaded(itemTypeId: string, source: string) {
  if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
    return;
  }
  console.debug('[match3d] generated model loaded', {
    itemTypeId,
    source,
  });
}

function debugMatch3DGeneratedModelMapped(itemTypeId: string, source: string) {
  if (!shouldLogMatch3DGeneratedModelDiagnostics()) {
    return;
  }
  console.debug('[match3d] generated model mapped', {
    itemTypeId,
    source,
  });
}

async function loadMatch3DGeneratedModelTemplate(
  templateMap: Match3DGeneratedModelTemplateMap,
  three: ThreeModule,
  itemTypeId: string,
  source: string,
  signal?: AbortSignal,
) {
  const cached = templateMap.get(itemTypeId);
  if (cached?.source === source) {
    return cached.scene;
  }
  const bytes = await readMatch3DGeneratedModelBytes(source, {
    expireSeconds: 300,
    signal,
  });
  if (bytes.byteLength === 0) {
    throw new Error('抓大鹅 3D 模型内容为空');
  }
  if (signal?.aborted) {
    throw new DOMException('加载已取消', 'AbortError');
  }
  const [{ GLTFLoader }] = await Promise.all([
    import('three/examples/jsm/loaders/GLTFLoader.js'),
  ]);
  const loader = new GLTFLoader();
  const gltf = await loader.parseAsync(bytes, '');
  if (signal?.aborted) {
    throw new DOMException('加载已取消', 'AbortError');
  }
  const scene = gltf.scene;
  scene.traverse((child) => {
    child.castShadow = true;
    child.receiveShadow = true;
  });
  const previous = templateMap.get(itemTypeId);
  if (previous && previous.source !== source) {
    disposeThreeObject(previous.scene);
  }
  templateMap.set(itemTypeId, {
    scene,
    source,
  });
  debugMatch3DGeneratedModelLoaded(itemTypeId, source);
  return scene;
}

function cloneThreeObjectWithMaterials(template: ThreeObject3D) {
  const clone = template.clone(true);
  clone.traverse((child) => {
    const maybeMesh = child as import('three').Mesh;
    if (maybeMesh.geometry) {
      maybeMesh.geometry = maybeMesh.geometry.clone();
    }
    if (maybeMesh.material) {
      maybeMesh.material = Array.isArray(maybeMesh.material)
        ? maybeMesh.material.map((material) => material.clone())
        : maybeMesh.material.clone();
    }
  });
  return clone;
}

function createGeneratedModelMesh(
  three: ThreeModule,
  item: Match3DItemSnapshot,
  templateMap: Match3DGeneratedModelTemplateMap | null | undefined,
) {
  const template = templateMap?.get(item.itemTypeId)?.scene;
  if (!template) {
    return null;
  }
  const position = toWorldPosition(item);
  const model = cloneThreeObjectWithMaterials(template);
  const bounds = new three.Box3().setFromObject(model);
  const size = bounds.getSize(new three.Vector3());
  const dimension = Math.max(size.x, size.y, size.z, 0.001);
  const targetDimension =
    position.radius * MATCH3D_GENERATED_MODEL_TARGET_RADIUS_SCALE;
  const scale = targetDimension / dimension;
  model.scale.multiplyScalar(scale);

  const scaledBounds = new three.Box3().setFromObject(model);
  const center = scaledBounds.getCenter(new three.Vector3());
  model.position.sub(center);
  const bottomY = scaledBounds.min.y - center.y;
  model.position.y -= bottomY;
  const pivot = new three.Group();
  pivot.add(model);
  markObjectForItem(pivot, item.itemInstanceId);

  return {
    lockReadableTop: false,
    mesh: pivot,
    radius: position.radius,
    shape: 'brick' as Match3DGeometryShape,
    topRotationY: ((item.layer % 12) / 12) * Math.PI * 2,
    position,
  };
}

function hasWebGLSupport() {
  try {
    const canvas = document.createElement('canvas');
    return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
  } catch {
    return false;
  }
}

function toWorldPosition(item: Match3DItemSnapshot) {
  const frame = resolveRenderableItemFrame(item);
  const radius = Math.max(0.28, frame.radius * MATCH3D_POT_FLOOR_RADIUS * 1.02);
  let x = (frame.x - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2;
  let z = (frame.y - MATCH3D_BOARD_CENTER) * MATCH3D_ITEM_POSITION_RADIUS * 2;
  const horizontalDistance = Math.hypot(x, z);
  const maxDistance = Math.max(0, MATCH3D_ITEM_ACTIVITY_RADIUS - radius * 1.1);
  if (horizontalDistance > maxDistance && horizontalDistance > 0) {
    const ratio = maxDistance / horizontalDistance;
    x *= ratio;
    z *= ratio;
  }
  return {
    x,
    z,
    radius,
  };
}

export function resolveMatch3DBoardDepthPlan(
  totalItemCount: number,
  activeItemCount: number,
): BoardDepthPlan {
  const normalizedTotalItemCount = Math.max(
    1,
    Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
  );
  const normalizedActiveItemCount = Math.max(
    0,
    Math.min(
      normalizedTotalItemCount,
      Math.round(Number.isFinite(activeItemCount) ? activeItemCount : 0),
    ),
  );
  const volumePressure = Math.max(0, normalizedTotalItemCount - 90);
  const depthMax =
    MATCH3D_ITEM_VERTICAL_DEPTH_MAX_BASE +
    volumePressure * MATCH3D_ITEM_VERTICAL_DEPTH_MAX_COUNT_SCALE;
  const initialDepth = Math.min(
    depthMax,
    MATCH3D_ITEM_VERTICAL_DEPTH_BASE +
      Math.sqrt(normalizedTotalItemCount) *
        MATCH3D_ITEM_VERTICAL_DEPTH_SQRT_SCALE +
      normalizedTotalItemCount * MATCH3D_ITEM_VERTICAL_DEPTH_COUNT_SCALE,
  );
  const remainingRatio = normalizedActiveItemCount / normalizedTotalItemCount;
  const activeDepth =
    normalizedActiveItemCount <= 1 ? 0 : initialDepth * remainingRatio;
  const pressureRatio = Math.min(1, volumePressure / 210);
  const layerCapacity = Math.max(
    MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_MIN,
    Math.round(MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE - pressureRatio * 8),
  );
  const layerCount = Math.max(
    1,
    Math.ceil(normalizedActiveItemCount / layerCapacity),
  );
  const layerStep =
    layerCount <= 1
      ? 0
      : Math.min(
          MATCH3D_ITEM_VERTICAL_LAYER_STEP_MAX,
          activeDepth / Math.max(1, layerCount - 1),
        );

  return {
    activeDepth,
    activeItemCount: normalizedActiveItemCount,
    baseY: MATCH3D_ITEM_BASE_HEIGHT,
    initialDepth,
    layerCapacity,
    layerCount,
    layerStep,
    maxVerticalSpeed:
      MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT + pressureRatio * 2.4,
    surfaceY: MATCH3D_ITEM_BASE_HEIGHT + initialDepth,
  };
}

export function resolveMatch3DStackTargetY(
  totalItemCount: number,
  activeItemCount: number,
  activeLayerRank: number,
) {
  const depthPlan = resolveMatch3DBoardDepthPlan(
    totalItemCount,
    activeItemCount,
  );
  if (depthPlan.activeItemCount <= 1) {
    return depthPlan.surfaceY;
  }
  const clampedRank = Math.max(
    0,
    Math.min(depthPlan.activeItemCount - 1, activeLayerRank),
  );
  const layerIndex = Math.floor(
    (clampedRank / Math.max(1, depthPlan.activeItemCount - 1)) *
      (depthPlan.layerCount - 1),
  );

  return (
    depthPlan.surfaceY -
    (depthPlan.layerCount - 1 - layerIndex) * depthPlan.layerStep
  );
}

export function resolveMatch3DBoundaryRadius(
  asset: Match3DGeometryAsset,
  radius: number,
) {
  const bounds = resolveMatch3DColliderBounds(asset, radius);
  return Math.hypot(bounds.width / 2, bounds.depth / 2);
}

export function resolveMatch3DSpawnY(
  plannedSpawnY: number,
  colliderHeight: number,
  boundaryRadius: number,
  position: Pick<Match3DSpawnHeightObstacle, 'x' | 'z'>,
  obstacles: readonly Match3DSpawnHeightObstacle[],
) {
  const normalizedPlannedY = Number.isFinite(plannedSpawnY)
    ? plannedSpawnY
    : MATCH3D_ITEM_BASE_HEIGHT;
  const selfHalfHeight = Math.max(0, colliderHeight / 2);
  const selfBoundaryRadius = Math.max(0, boundaryRadius);

  return obstacles.reduce((spawnY, obstacle) => {
    const horizontalDistance = Math.hypot(
      position.x - obstacle.x,
      position.z - obstacle.z,
    );
    const overlapDistance =
      selfBoundaryRadius +
      Math.max(0, obstacle.boundaryRadius) +
      MATCH3D_ITEM_SPAWN_STACK_RADIUS_PADDING;
    if (horizontalDistance > overlapDistance) {
      return spawnY;
    }

    // 中文注释：新物体生成时先避开同位置已有堆叠顶部，避免最后一波直接塞进未稳定的上层模型。
    const obstacleTopY = obstacle.y + Math.max(0, obstacle.colliderHeight) / 2;
    return Math.max(
      spawnY,
      obstacleTopY + selfHalfHeight + MATCH3D_ITEM_SPAWN_STACK_CLEARANCE,
    );
  }, normalizedPlannedY);
}

export function resolveMatch3DSpawnDelay(
  activeLayerRank: number,
  layerCapacity = MATCH3D_ITEM_VERTICAL_LAYER_CAPACITY_BASE,
  timingPlan?: Pick<
    Match3DSpawnTimingPlan,
    'burstSize' | 'layerDelayMs' | 'staggerMs'
  >,
) {
  const normalizedLayerCapacity = Math.max(1, layerCapacity);
  const normalizedLayerRank = Math.max(0, activeLayerRank);
  const layerDelayMs =
    timingPlan?.layerDelayMs ?? MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS;
  const staggerMs = timingPlan?.staggerMs ?? MATCH3D_ITEM_SPAWN_STAGGER_MS;
  const burstSize = Math.max(
    1,
    timingPlan?.burstSize ?? normalizedLayerCapacity,
  );
  const layerIndex = Math.floor(normalizedLayerRank / normalizedLayerCapacity);
  const burstIndex = Math.floor(normalizedLayerRank / burstSize);
  return (
    Math.max(layerIndex, burstIndex) * layerDelayMs +
    (normalizedLayerRank % burstSize) * staggerMs
  );
}

export function resolveMatch3DSpawnTimingPlan(
  totalItemCount: number,
): Match3DSpawnTimingPlan {
  const normalizedTotalItemCount = Math.max(
    1,
    Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
  );
  const crowdPressureRatio = Math.min(
    1,
    Math.max(0, normalizedTotalItemCount - 50) / 250,
  );
  const highCrowdRatio = Math.pow(crowdPressureRatio, 0.82);
  const midCrowdRatio = Math.min(
    1,
    Math.max(0, normalizedTotalItemCount - 30) / 20,
  );

  return {
    frameSpawnLimit:
      normalizedTotalItemCount < 30
        ? 4
        : normalizedTotalItemCount <= 120
          ? 2
          : 1,
    initialDelayMs: Math.round(
      normalizedTotalItemCount < 30
        ? 220
        : normalizedTotalItemCount <= 50
          ? 240 + midCrowdRatio * 40
          : 260 + highCrowdRatio * 140,
    ),
    layerDelayMs: Math.round(
      normalizedTotalItemCount < 30
        ? MATCH3D_ITEM_SPAWN_LAYER_DELAY_MS
        : normalizedTotalItemCount <= 50
          ? 96 + midCrowdRatio * 24
          : 110 + highCrowdRatio * 50,
    ),
    burstSize:
      normalizedTotalItemCount < 30
        ? 8
        : normalizedTotalItemCount <= 50
          ? 5
          : normalizedTotalItemCount <= 120
            ? 7
            : 6,
    staggerMs: Math.round(
      normalizedTotalItemCount < 30
        ? MATCH3D_ITEM_SPAWN_STAGGER_MS
        : normalizedTotalItemCount <= 50
          ? 9 + midCrowdRatio * 3
          : 12 + highCrowdRatio * 6,
    ),
  };
}

function buildMatch3DStackHeightTargets(
  run: Match3DRunSnapshot,
): Match3DStackHeightPlan {
  const activeItems = run.items
    .filter((item) => isItemState(item.state, 'in_board'))
    .sort((left, right) => {
      if (left.layer !== right.layer) {
        return left.layer - right.layer;
      }
      return left.itemInstanceId.localeCompare(right.itemInstanceId);
    });
  const targets = new Map<string, StackHeightTarget>();
  const depthPlan = resolveMatch3DBoardDepthPlan(
    run.totalItemCount,
    activeItems.length,
  );
  activeItems.forEach((item, activeLayerRank) => {
    targets.set(item.itemInstanceId, {
      activeLayerRank,
      targetY: resolveMatch3DStackTargetY(
        run.totalItemCount,
        activeItems.length,
        activeLayerRank,
      ),
    });
  });
  return {
    layerCapacity: depthPlan.layerCapacity,
    targets,
  };
}

export function resolveMatch3DPhysicsStabilityPlan(
  totalItemCount: number,
): PhysicsStabilityPlan {
  const normalizedTotalItemCount = Math.max(
    1,
    Math.round(Number.isFinite(totalItemCount) ? totalItemCount : 1),
  );
  const pressureRatio = Math.min(
    1,
    Math.max(0, normalizedTotalItemCount - 90) / 210,
  );

  return {
    angularDamping: 0.48 + pressureRatio * 0.18,
    contactFriction: 0.55 + pressureRatio * 0.22,
    contactRestitution: 0.28 - pressureRatio * 0.14,
    linearDamping: 0.38 + pressureRatio * 0.2,
    maxHorizontalSpeed:
      MATCH3D_ITEM_EXTREME_HORIZONTAL_SPEED_LIMIT - pressureRatio * 0.9,
    maxVerticalSpeed:
      MATCH3D_ITEM_EXTREME_VERTICAL_SPEED_LIMIT + pressureRatio * 2.4,
    solverIterations: Math.round(10 + pressureRatio * 8),
    solverTolerance: 0.001 - pressureRatio * 0.0006,
  };
}

function applyDynamicStackLift(entry: PhysicsEntry) {
  const liftDistance = entry.targetY - entry.body.position.y;
  if (liftDistance <= 0.04) {
    return;
  }
  const liftSpeed = Math.min(
    MATCH3D_ITEM_LIFT_MAX_SPEED,
    Math.max(0.7, liftDistance * 1.8),
  );

  // 中文注释：纵深只作为隐藏的表现层支撑；消除后给低层物体向上的托举，避免它们长期陷在锅底。
  entry.body.force.y +=
    entry.body.mass * liftDistance * MATCH3D_ITEM_LIFT_FORCE_SCALE;
  entry.body.velocity.y = Math.max(entry.body.velocity.y, liftSpeed);
  entry.body.wakeUp();
}

function applyStabilityPlanToBody(
  entry: PhysicsEntry,
  stabilityPlan: PhysicsStabilityPlan,
) {
  const horizontalSpeed = Math.hypot(
    entry.body.velocity.x,
    entry.body.velocity.z,
  );
  if (horizontalSpeed > stabilityPlan.maxHorizontalSpeed) {
    const ratio = stabilityPlan.maxHorizontalSpeed / horizontalSpeed;
    entry.body.velocity.x *= ratio;
    entry.body.velocity.z *= ratio;
  }
  entry.body.velocity.y = Math.max(
    -stabilityPlan.maxVerticalSpeed,
    Math.min(stabilityPlan.maxVerticalSpeed, entry.body.velocity.y),
  );
}

function syncRuntimeStabilityPlan(
  runtime: PhysicsRuntime,
  totalItemCount: number,
) {
  const stabilityPlan = resolveMatch3DPhysicsStabilityPlan(totalItemCount);
  runtime.stabilityPlan = stabilityPlan;
  runtime.world.defaultContactMaterial.friction = stabilityPlan.contactFriction;
  runtime.world.defaultContactMaterial.restitution =
    stabilityPlan.contactRestitution;
  const solver = runtime.world.solver as import('cannon-es').GSSolver;
  solver.iterations = stabilityPlan.solverIterations;
  solver.tolerance = stabilityPlan.solverTolerance;

  runtime.entries.forEach((entry) => {
    entry.body.angularDamping = stabilityPlan.angularDamping;
    entry.body.linearDamping = stabilityPlan.linearDamping;
    entry.body.sleepSpeedLimit = Math.max(
      0.08,
      0.16 - Math.min(1, totalItemCount / 300) * 0.05,
    );
    entry.body.sleepTimeLimit = 0.18;
  });
}

function constrainBodyInsidePot(entry: PhysicsEntry) {
  // 中文注释：空气墙按真实碰撞外接半径收束，长条积木不能再只按近似圆半径贴近锅边。
  const maxDistance = Math.max(
    0,
    MATCH3D_ITEM_ACTIVITY_RADIUS - entry.boundaryRadius,
  );
  const horizontalDistance = Math.hypot(
    entry.body.position.x,
    entry.body.position.z,
  );
  if (horizontalDistance <= maxDistance || horizontalDistance <= 0) {
    return;
  }

  const normalX = entry.body.position.x / horizontalDistance;
  const normalZ = entry.body.position.z / horizontalDistance;
  entry.body.position.x = normalX * maxDistance;
  entry.body.position.z = normalZ * maxDistance;

  const outwardSpeed =
    entry.body.velocity.x * normalX + entry.body.velocity.z * normalZ;
  if (outwardSpeed > 0) {
    entry.body.velocity.x -= normalX * outwardSpeed * 1.35;
    entry.body.velocity.z -= normalZ * outwardSpeed * 1.35;
  }
}

function resolveSpawnAnimationProgress(entry: PhysicsEntry, now: number) {
  return Math.min(
    1,
    Math.max(0, (now - entry.spawnStartedAt) / MATCH3D_ITEM_SPAWN_ANIMATION_MS),
  );
}

export function resolveMatch3DSpawnVisualScale(progress: number) {
  const clampedProgress = Math.min(
    1,
    Math.max(0, Number.isFinite(progress) ? progress : 0),
  );
  const easedProgress = 1 - Math.pow(1 - clampedProgress, 3);
  return (
    MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START +
    (1 - MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START) * easedProgress
  );
}

function applyCenterGravity(entry: PhysicsEntry) {
  if (MATCH3D_CENTER_GRAVITY_COEFFICIENT <= 0) {
    return;
  }

  const horizontalDistance = Math.hypot(
    entry.body.position.x,
    entry.body.position.z,
  );
  if (horizontalDistance <= 0.08) {
    return;
  }

  const visualRadius = toWorldPosition(entry.item).radius;
  const maxDistance = Math.max(
    0.1,
    MATCH3D_ITEM_ACTIVITY_RADIUS - visualRadius * 1.05,
  );
  const edgePressure = Math.min(1, horizontalDistance / maxDistance);
  const centerFalloff = Math.min(
    1,
    Math.max(0, (horizontalDistance - 1.15) / maxDistance),
  );
  const forceStrength =
    MATCH3D_CENTER_GRAVITY_COEFFICIENT *
    entry.body.mass *
    (10.5 + edgePressure * 13) *
    centerFalloff;

  // 中文注释：中心引力只拉水平面，垂直方向仍交给锅底重力和物体堆叠处理。
  entry.body.force.x +=
    (-entry.body.position.x / horizontalDistance) * forceStrength;
  entry.body.force.z +=
    (-entry.body.position.z / horizontalDistance) * forceStrength;
}

export function resolveMatch3DColliderBounds(
  asset: Match3DGeometryAsset,
  radius: number,
) {
  switch (asset.shape) {
    case 'cylinder':
      return {
        depth: radius * 1.16,
        height: radius * 1.312,
        width: radius * 1.16,
      };
    case 'cone':
      return {
        depth: radius * 1.36,
        height: radius * 1.48,
        width: radius * 1.36,
      };
    case 'ring':
      return {
        depth: radius * 1.84,
        height: radius * 0.42,
        width: radius * 1.84,
      };
    case 'arch':
      return {
        depth: radius * 1.5,
        height: radius * 0.42,
        width: radius * 2,
      };
    case 'slope':
      return {
        depth: radius * (0.95 + asset.studsY * 0.62),
        height: radius * asset.heightScale + radius * 0.12,
        width: radius * (1 + asset.studsX * 0.66),
      };
    case 'tile':
      return {
        depth: radius * (0.9 + asset.studsY * 0.62),
        height: Math.max(radius * 0.24, radius * asset.heightScale),
        width: radius * (0.9 + asset.studsX * 0.62),
      };
    case 'brick':
    default:
      return {
        depth: radius * (0.9 + asset.studsY * 0.62),
        height:
          Math.max(radius * 0.24, radius * asset.heightScale) + radius * 0.12,
        width: radius * (0.9 + asset.studsX * 0.62),
      };
  }
}

export function createMatch3DCannonShape(
  cannon: CannonModule,
  asset: Match3DGeometryAsset,
  radius: number,
): CannonShape {
  const bounds = resolveMatch3DColliderBounds(asset, radius);
  switch (asset.shape) {
    case 'cylinder':
    case 'ring':
      return new cannon.Cylinder(
        bounds.width / 2,
        bounds.width / 2,
        bounds.height,
        asset.shape === 'ring' ? 24 : 18,
      );
    case 'cone':
      return new cannon.Cylinder(0, bounds.width / 2, bounds.height, 24);
    default:
      return new cannon.Box(
        new cannon.Vec3(bounds.width / 2, bounds.height / 2, bounds.depth / 2),
      );
  }
}

function buildPointShape(
  three: ThreeModule,
  radius: number,
  points: Array<[number, number]>,
) {
  const shape = new three.Shape();
  points.forEach(([x, y], index) => {
    if (index === 0) {
      shape.moveTo(x * radius, y * radius);
    } else {
      shape.lineTo(x * radius, y * radius);
    }
  });
  shape.closePath();
  return shape;
}

function buildRingShape(three: ThreeModule, radius: number) {
  const shape = new three.Shape();
  shape.absarc(0, 0, radius * 0.92, 0, Math.PI * 2, false);
  const hole = new three.Path();
  hole.absarc(0, 0, radius * 0.43, 0, Math.PI * 2, true);
  shape.holes.push(hole);
  return shape;
}

function buildReadableShape(
  three: ThreeModule,
  shape: Match3DGeometryShape,
  radius: number,
) {
  switch (shape) {
    case 'ring':
      return buildRingShape(three, radius);
    case 'arch':
      return buildPointShape(three, radius, [
        [-1, 0.8],
        [1, 0.8],
        [1, -0.7],
        [0.42, -0.7],
        [0.42, 0.24],
        [-0.42, 0.24],
        [-0.42, -0.7],
        [-1, -0.7],
      ]);
    default:
      return null;
  }
}

function createExtrudedReadableGeometry(
  three: ThreeModule,
  shape: Match3DGeometryShape,
  radius: number,
) {
  const path = buildReadableShape(three, shape, radius);
  if (!path) {
    return null;
  }
  const geometry = new three.ExtrudeGeometry(path, {
    bevelEnabled: true,
    bevelSegments: 2,
    bevelSize: radius * 0.045,
    bevelThickness: radius * 0.04,
    depth: radius * 0.42,
    steps: 1,
  });
  geometry.center();
  geometry.rotateX(-Math.PI / 2);
  return geometry;
}

export function createMatch3DThreeGeometry(
  three: ThreeModule,
  shape: Match3DGeometryShape,
  radius: number,
) {
  const readableGeometry = createExtrudedReadableGeometry(three, shape, radius);
  if (readableGeometry) {
    return readableGeometry;
  }

  switch (shape) {
    case 'cylinder':
      return new three.CylinderGeometry(
        radius * 0.72,
        radius * 0.72,
        radius * 1.35,
        26,
      );
    case 'cone':
      return new three.ConeGeometry(radius * 0.78, radius * 1.62, 28);
    case 'tile':
    case 'brick':
    case 'slope':
    case 'arch':
    default:
      return new three.BoxGeometry(radius * 1.8, radius * 0.9, radius * 1.2);
  }
}

function createRoundedBlockBase(
  three: ThreeModule,
  asset: Match3DGeometryAsset,
  radius: number,
) {
  const width = radius * (0.9 + asset.studsX * 0.62);
  const depth = radius * (0.9 + asset.studsY * 0.62);
  const height = Math.max(radius * 0.24, radius * asset.heightScale);
  return new three.BoxGeometry(width, height, depth);
}

function createStudGeometry(three: ThreeModule, radius: number) {
  return new three.CylinderGeometry(
    radius * 0.18,
    radius * 0.18,
    radius * 0.12,
    20,
  );
}

function createSlopeGeometry(
  three: ThreeModule,
  asset: Match3DGeometryAsset,
  radius: number,
) {
  const width = radius * (1 + asset.studsX * 0.66);
  const depth = radius * (0.95 + asset.studsY * 0.62);
  const height = radius * asset.heightScale;
  const halfW = width / 2;
  const halfD = depth / 2;
  const halfH = height / 2;
  const vertices = new Float32Array([
    -halfW,
    -halfH,
    -halfD,
    halfW,
    -halfH,
    -halfD,
    halfW,
    -halfH,
    halfD,
    -halfW,
    -halfH,
    halfD,
    halfW,
    halfH,
    -halfD,
    halfW,
    halfH,
    halfD,
  ]);
  const indices = [
    0, 1, 2, 0, 2, 3, 1, 4, 5, 1, 5, 2, 3, 2, 5, 3, 5, 0, 0, 5, 4, 0, 4, 1,
  ];
  const geometry = new three.BufferGeometry();
  geometry.setAttribute('position', new three.BufferAttribute(vertices, 3));
  geometry.setIndex(indices);
  geometry.computeVertexNormals();
  return geometry;
}

function addBrickStuds(
  three: ThreeModule,
  group: import('three').Group,
  asset: Match3DGeometryAsset,
  radius: number,
  material: import('three').Material,
) {
  if (asset.shape === 'tile') {
    return;
  }
  const studGeometry = createStudGeometry(three, radius);
  const width = radius * (0.9 + asset.studsX * 0.62);
  const depth = radius * (0.9 + asset.studsY * 0.62);
  const y =
    Math.max(radius * 0.24, radius * asset.heightScale) / 2 + radius * 0.06;
  for (let row = 0; row < asset.studsY; row += 1) {
    for (let column = 0; column < asset.studsX; column += 1) {
      const stud = new three.Mesh(studGeometry.clone(), material);
      stud.position.set(
        ((column + 0.5) / asset.studsX - 0.5) * width * 0.74,
        y,
        ((row + 0.5) / asset.studsY - 0.5) * depth * 0.72,
      );
      group.add(stud);
    }
  }
}

function createBlockMesh(
  three: ThreeModule,
  asset: Match3DGeometryAsset,
  radius: number,
  material: import('three').Material,
) {
  const group = new three.Group();
  let baseGeometry: import('three').BufferGeometry;
  if (asset.shape === 'slope') {
    baseGeometry = createSlopeGeometry(three, asset, radius);
  } else if (asset.shape === 'cylinder') {
    baseGeometry = new three.CylinderGeometry(
      radius * 0.58,
      radius * 0.58,
      radius * 1.18,
      28,
    );
  } else if (asset.shape === 'cone') {
    baseGeometry = new three.ConeGeometry(radius * 0.68, radius * 1.48, 30);
  } else if (MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape)) {
    baseGeometry = createMatch3DThreeGeometry(three, asset.shape, radius);
  } else {
    baseGeometry = createRoundedBlockBase(three, asset, radius);
  }
  const base = new three.Mesh(baseGeometry, material);
  group.add(base);

  if (asset.shape === 'brick' || asset.shape === 'slope') {
    addBrickStuds(three, group, asset, radius, material);
  }
  if (asset.shape === 'cylinder') {
    const topStud = new three.Mesh(
      createStudGeometry(three, radius * 1.2),
      material,
    );
    topStud.position.y = radius * 0.65;
    group.add(topStud);
  }
  if (asset.shape === 'cone') {
    const lip = new three.Mesh(
      new three.TorusGeometry(radius * 0.38, radius * 0.07, 8, 24),
      material,
    );
    lip.rotation.x = Math.PI / 2;
    lip.position.y = radius * 0.52;
    group.add(lip);
  }
  return group;
}

function markObjectForItem(object: ThreeObject3D, itemInstanceId: string) {
  object.userData.itemInstanceId = itemInstanceId;
  object.traverse((child) => {
    child.userData.itemInstanceId = itemInstanceId;
    child.castShadow = true;
    child.receiveShadow = true;
  });
}

function disposeThreeObject(object: ThreeObject3D) {
  object.traverse((child) => {
    const maybeMesh = child as import('three').Mesh;
    maybeMesh.geometry?.dispose();
    const material = maybeMesh.material;
    if (Array.isArray(material)) {
      material.forEach((item) => item.dispose());
    } else {
      material?.dispose();
    }
  });
}

export function createMatch3DItemMesh(
  three: ThreeModule,
  item: Match3DItemSnapshot,
) {
  const asset = resolveGeometryAsset(item.visualKey);
  const position = toWorldPosition(item);
  const material = new three.MeshStandardMaterial({
    color: asset.fill,
    emissive: asset.fill,
    emissiveIntensity: 0.08,
    metalness: 0.16,
    opacity: asset.transparent ? 0.58 : 1,
    roughness: 0.46,
    transparent: Boolean(asset.transparent),
    side: MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape)
      ? three.DoubleSide
      : three.FrontSide,
  });
  const mesh = createBlockMesh(three, asset, position.radius, material);
  markObjectForItem(mesh, item.itemInstanceId);
  return {
    lockReadableTop: MATCH3D_EXTRUDED_READABLE_SHAPES.has(asset.shape),
    mesh,
    radius: position.radius,
    shape: asset.shape,
    topRotationY: ((item.layer % 12) / 12) * Math.PI * 2,
    position,
  };
}

function createItemMesh(
  three: ThreeModule,
  item: Match3DItemSnapshot,
  templateMap?: Match3DGeneratedModelTemplateMap | null,
) {
  return (
    createGeneratedModelMesh(three, item, templateMap) ??
    createMatch3DItemMesh(three, item)
  );
}

function shouldWaitForGeneratedModelTemplate(
  generatedModelByType: Map<string, Match3DGeneratedItemAsset>,
  templateMap: Match3DGeneratedModelTemplateMap,
  failedTypeIds: ReadonlySet<string>,
  itemTypeId: string,
) {
  const source = resolveGeneratedModelSourceForItemType(
    generatedModelByType,
    itemTypeId,
  );
  // 中文注释：坏 GLB 或过期链接不能让整局空等模板；失败类型应立即走默认几何降级。
  return Boolean(
    source && !templateMap.has(itemTypeId) && !failedTypeIds.has(itemTypeId),
  );
}

export function buildMatch3DPhysicsEntrySignature(
  runId: string,
  item: Match3DItemSnapshot,
  generatedModelSource = '',
  generatedModelRevision = 0,
) {
  return [
    runId,
    item.itemInstanceId,
    item.itemTypeId,
    item.visualKey,
    item.radius.toFixed(5),
    item.layer,
    generatedModelSource,
    generatedModelRevision,
  ].join(':');
}

function removePhysicsEntry(
  runtime: PhysicsRuntime,
  itemInstanceId: string,
  entry: PhysicsEntry,
) {
  runtime.scene.remove(entry.mesh);
  runtime.world.removeBody(entry.body);
  disposeThreeObject(entry.mesh);
  runtime.entries.delete(itemInstanceId);
}

function resolveMatch3DPhysicsHitItemId(
  runtime: PhysicsRuntime,
  container: HTMLElement,
  clientX: number,
  clientY: number,
) {
  const rect = container.getBoundingClientRect();
  if (rect.width <= 0 || rect.height <= 0) {
    return null;
  }
  const pointer = new runtime.three.Vector2(
    ((clientX - rect.left) / rect.width) * 2 - 1,
    -(((clientY - rect.top) / rect.height) * 2 - 1),
  );
  runtime.raycaster.setFromCamera(pointer, runtime.camera);
  const meshes = [...runtime.entries.values()]
    .filter(
      (entry) =>
        entry.item.clickable &&
        isItemState(entry.item.state, 'in_board') &&
        entry.mesh.visible,
    )
    .map((entry) => entry.mesh);
  const hit = runtime.raycaster.intersectObjects(meshes, true)[0];
  return typeof hit?.object.userData.itemInstanceId === 'string'
    ? hit.object.userData.itemInstanceId
    : null;
}

function createPhysicsEntryFromPendingSpawn(
  runtime: PhysicsRuntime,
  pendingSpawn: PendingPhysicsSpawn,
  now: number,
  templateMap?: Match3DGeneratedModelTemplateMap | null,
) {
  if (
    shouldWaitForGeneratedModelTemplate(
      runtime.generatedModelByType,
      runtime.generatedModelTemplates,
      runtime.failedGeneratedModelTypeIds,
      pendingSpawn.item.itemTypeId,
    )
  ) {
    return;
  }
  const visual = createItemMesh(runtime.three, pendingSpawn.item, templateMap);
  const asset = resolveGeometryAsset(pendingSpawn.item.visualKey);
  const colliderBounds = resolveMatch3DColliderBounds(asset, visual.radius);
  const boundaryRadius = resolveMatch3DBoundaryRadius(asset, visual.radius);
  const position = visual.position;
  const maxDistance = Math.max(
    0,
    MATCH3D_ITEM_ACTIVITY_RADIUS - boundaryRadius,
  );
  const horizontalDistance = Math.hypot(position.x, position.z);
  if (horizontalDistance > maxDistance && horizontalDistance > 0) {
    const ratio = maxDistance / horizontalDistance;
    position.x *= ratio;
    position.z *= ratio;
  }
  const spawnLayerIndex = Math.floor(
    Math.max(0, pendingSpawn.activeLayerRank) / pendingSpawn.layerCapacity,
  );
  const plannedSpawnY =
    pendingSpawn.targetY +
    MATCH3D_ITEM_SPAWN_RISE_OFFSET +
    Math.min(spawnLayerIndex * 0.05, 0.62);
  const spawnY = resolveMatch3DSpawnY(
    plannedSpawnY,
    colliderBounds.height,
    boundaryRadius,
    position,
    [...runtime.entries.values()].map((entry) => ({
      boundaryRadius: entry.boundaryRadius,
      colliderHeight: entry.colliderHeight,
      x: entry.body.position.x,
      y: entry.body.position.y,
      z: entry.body.position.z,
    })),
  );
  const body = new runtime.cannon.Body({
    angularDamping: runtime.stabilityPlan.angularDamping,
    allowSleep: true,
    linearDamping: runtime.stabilityPlan.linearDamping,
    mass: 1 + visual.radius * 0.7,
    shape: createMatch3DCannonShape(runtime.cannon, asset, visual.radius),
    sleepSpeedLimit: 0.12,
    sleepTimeLimit: 0.18,
    position: new runtime.cannon.Vec3(position.x, spawnY, position.z),
  });
  body.velocity.set(
    ((pendingSpawn.item.layer % 5) - 2) * 0.06,
    -0.35,
    (((pendingSpawn.item.layer + 2) % 5) - 2) * 0.06,
  );
  body.angularVelocity.set(
    0.1 + (pendingSpawn.item.layer % 3) * 0.025,
    0.08,
    0.08 + (pendingSpawn.item.layer % 4) * 0.02,
  );
  const baseVisualScale = visual.mesh.scale.clone();
  visual.mesh.scale
    .copy(baseVisualScale)
    .multiplyScalar(MATCH3D_ITEM_SPAWN_VISUAL_SCALE_START);

  runtime.world.addBody(body);
  runtime.scene.add(visual.mesh);
  runtime.entries.set(pendingSpawn.item.itemInstanceId, {
    baseVisualScale,
    body,
    boundaryRadius,
    colliderHeight: colliderBounds.height,
    item: pendingSpawn.item,
    lockReadableTop: visual.lockReadableTop,
    mesh: visual.mesh,
    renderSignature: pendingSpawn.renderSignature,
    spawnStartedAt: now,
    targetY: pendingSpawn.targetY,
    topRotationY: visual.topRotationY,
  });
}

function flushPendingPhysicsSpawns(runtime: PhysicsRuntime, now: number) {
  const readySpawns = [...runtime.pendingSpawns.entries()]
    .filter(([, pendingSpawn]) => {
      if (now < pendingSpawn.spawnAtMs) {
        return false;
      }
      return !shouldWaitForGeneratedModelTemplate(
        runtime.generatedModelByType,
        runtime.generatedModelTemplates,
        runtime.failedGeneratedModelTypeIds,
        pendingSpawn.item.itemTypeId,
      );
    })
    .sort((left, right) => {
      if (left[1].spawnAtMs !== right[1].spawnAtMs) {
        return left[1].spawnAtMs - right[1].spawnAtMs;
      }
      if (left[1].activeLayerRank !== right[1].activeLayerRank) {
        return left[1].activeLayerRank - right[1].activeLayerRank;
      }
      return left[0].localeCompare(right[0]);
    });
  const spawnBudget = runtime.spawnTimingPlan.frameSpawnLimit;
  readySpawns
    .slice(0, spawnBudget)
    .forEach(([itemInstanceId, pendingSpawn]) => {
      runtime.pendingSpawns.delete(itemInstanceId);
      createPhysicsEntryFromPendingSpawn(
        runtime,
        pendingSpawn,
        now,
        runtime.generatedModelTemplates,
      );
    });
}

function disposeRuntime(runtime: PhysicsRuntime | null) {
  if (!runtime) {
    return;
  }
  if (runtime.animationId !== null) {
    window.cancelAnimationFrame(runtime.animationId);
  }
  runtime.entries.forEach((entry) => {
    disposeThreeObject(entry.mesh);
  });
  runtime.generatedModelTemplates.forEach((template) => {
    disposeThreeObject(template.scene);
  });
  runtime.generatedModelTemplates.clear();
  runtime.renderer.dispose();
  runtime.renderer.domElement.remove();
}

type TrayPreviewRuntime = {
  animationId: number | null;
  camera: ThreeCamera;
  entries: Map<string, ThreeObject3D>;
  failedGeneratedModelTypeIds: Set<string>;
  generatedModelTemplates: Match3DGeneratedModelTemplateMap;
  renderer: ThreeRenderer;
  scene: ThreeScene;
  three: ThreeModule;
};

const MATCH3D_TRAY_SLOT_COUNT = 7;
const MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE = 0.5;
export const MATCH3D_TRAY_MODEL_TARGET_SIZE = 0.86;
export const MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE = 0.9;

function buildTrayPreviewMeasureKey(item: Match3DItemSnapshot) {
  return `${item.visualKey}:${item.radius.toFixed(5)}`;
}

function buildTrayPreviewSignature(
  item: Match3DItemSnapshot,
  referenceMaxDimension: number,
  generatedModelSource = '',
  generatedModelRevision = 0,
) {
  return [
    item.visualKey,
    item.itemTypeId,
    item.radius.toFixed(5),
    referenceMaxDimension.toFixed(5),
    MATCH3D_TRAY_MODEL_TARGET_SIZE.toFixed(5),
    MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE.toFixed(5),
    generatedModelSource,
    generatedModelRevision,
  ].join(':');
}

export function measureMatch3DItemPreviewDimension(
  three: ThreeModule,
  item: Match3DItemSnapshot,
) {
  const preview = createMatch3DItemMesh(three, item);
  const bounds = new three.Box3().setFromObject(preview.mesh);
  const size = bounds.getSize(new three.Vector3());
  disposeThreeObject(preview.mesh);
  return Math.max(size.x, size.y, size.z, 0.001);
}

export function resolveMatch3DTrayPreviewReferenceDimension(
  three: ThreeModule,
  referenceItems: Match3DItemSnapshot[],
) {
  const measuredDimensions = new Map<string, number>();
  let maxDimension = 0;
  for (const item of referenceItems) {
    const key = buildTrayPreviewMeasureKey(item);
    const dimension =
      measuredDimensions.get(key) ??
      measureMatch3DItemPreviewDimension(three, item);
    measuredDimensions.set(key, dimension);
    maxDimension = Math.max(maxDimension, dimension);
  }
  return Math.max(maxDimension, 0.001);
}

export function resolveMatch3DTrayPreviewRotation(visualKey: string) {
  const asset = resolveGeometryAsset(visualKey);
  const yaw = asset.studsX >= asset.studsY ? Math.PI / 4 : Math.PI / 5;

  // 中文注释：托盘里用轻微俯视 3/4 姿态展示体积，固定朝向只影响 UI 预览，不反写场内物理姿态。
  switch (asset.shape) {
    case 'tile':
    case 'ring':
      return {
        x: -0.28,
        y: yaw,
        z: 0.22,
      };
    case 'slope':
    case 'arch':
      return {
        x: -0.34,
        y: yaw,
        z: 0.24,
      };
    case 'cylinder':
    case 'cone':
      return {
        x: -0.3,
        y: Math.PI / 4,
        z: 0.2,
      };
    case 'brick':
    default:
      return {
        x: -0.32,
        y: yaw,
        z: 0.24,
      };
  }
}

export function resolveMatch3DTrayPreviewScale(
  itemDimension: number,
  referenceMaxDimension: number,
) {
  const maxScale =
    MATCH3D_TRAY_MODEL_TARGET_SIZE / Math.max(referenceMaxDimension, 0.001);
  const readableScale =
    (MATCH3D_TRAY_MODEL_TARGET_SIZE * MATCH3D_TRAY_MODEL_MIN_RELATIVE_SIZE) /
    Math.max(itemDimension, 0.001);
  return Math.max(maxScale, readableScale);
}

function disposeTrayPreview(runtime: TrayPreviewRuntime | null) {
  if (!runtime) {
    return;
  }
  if (runtime.animationId !== null) {
    window.cancelAnimationFrame(runtime.animationId);
  }
  runtime.entries.forEach((mesh) => {
    disposeThreeObject(mesh);
  });
  runtime.entries.clear();
  runtime.generatedModelTemplates.forEach((template) => {
    disposeThreeObject(template.scene);
  });
  runtime.generatedModelTemplates.clear();
  runtime.renderer.dispose();
  runtime.renderer.domElement.remove();
}

export function applyMatch3DRendererCanvasLayout(canvas: HTMLCanvasElement) {
  // 中文注释：WebGL 绘图缓冲区会乘设备 DPR，CSS 尺寸必须单独锁住，否则手机端画布会放大溢出。
  canvas.style.display = 'block';
  canvas.style.height = '100%';
  canvas.style.inset = '0';
  canvas.style.position = 'absolute';
  canvas.style.width = '100%';
}

function positionTrayPreviewObject(
  runtime: TrayPreviewRuntime,
  object: ThreeObject3D,
  slotIndex: number,
) {
  const slotWidth =
    (runtime.camera.right - runtime.camera.left) / MATCH3D_TRAY_SLOT_COUNT;
  const slotCenter = runtime.camera.left + slotWidth * (slotIndex + 0.5);
  const screenX = new runtime.three.Vector3(1, 0, 0).applyQuaternion(
    runtime.camera.quaternion,
  );
  // 中文注释：托盘模型按相机屏幕横轴排布，保留斜视角但不让 UI 格子投影成斜线。
  object.position.copy(screenX.multiplyScalar(slotCenter));
}

function relayoutTrayPreviewEntries(runtime: TrayPreviewRuntime) {
  runtime.entries.forEach((object) => {
    const slotIndex =
      typeof object.userData.traySlotIndex === 'number'
        ? object.userData.traySlotIndex
        : 0;
    positionTrayPreviewObject(runtime, object, slotIndex);
  });
}

export function buildMatch3DTrayModelSourceMap(
  referenceItems: Match3DItemSnapshot[],
  slotItems: Array<Match3DItemSnapshot | null>,
  generatedItemAssets: readonly Match3DGeneratedItemAsset[],
) {
  const itemTypeIds = resolveMatch3DGeneratedModelTypeIds([
    ...referenceItems,
    ...slotItems.filter((item): item is Match3DItemSnapshot => Boolean(item)),
  ]);
  const readyAssets = generatedItemAssets
    .map((asset) => ({
      asset,
      source: normalizeMatch3DGeneratedModelSource(asset),
    }))
    .filter(({ source }) => Boolean(source))
    .slice(0, MATCH3D_GENERATED_MODEL_MAX_ASSET_COUNT);
  const result = new Map<string, string>();

  itemTypeIds.forEach((itemTypeId, index) => {
    const resolved = readyAssets[index];
    if (!resolved) {
      return;
    }
    result.set(itemTypeId, resolved.source);
  });

  return result;
}

export function Match3DTrayPreviewBoard({
  onFallback,
  referenceItems,
  slotItems,
  generatedItemAssets = [],
}: {
  onFallback: () => void;
  referenceItems: Match3DItemSnapshot[];
  slotItems: Array<Match3DItemSnapshot | null>;
  generatedItemAssets?: Match3DGeneratedItemAsset[];
}) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const runtimeRef = useRef<TrayPreviewRuntime | null>(null);
  const [ready, setReady] = useState(false);
  const trayModelSourceByType = useMemo(
    () =>
      buildMatch3DTrayModelSourceMap(
        referenceItems,
        slotItems,
        generatedItemAssets,
      ),
    [generatedItemAssets, referenceItems, slotItems],
  );
  const trayModelSignature = useMemo(
    () =>
      [...trayModelSourceByType.entries()]
        .map(([type, source]) => `${type}:${source}`)
        .join('|'),
    [trayModelSourceByType],
  );
  const [trayModelRevision, setTrayModelRevision] = useState(0);

  useEffect(() => {
    let cancelled = false;
    let cleanupResize: (() => void) | undefined;

    async function setup() {
      const container = containerRef.current;
      if (!container || !hasWebGLSupport()) {
        onFallback();
        return;
      }

      try {
        const three = await import('three');
        if (cancelled || !containerRef.current) {
          return;
        }

        const renderer = new three.WebGLRenderer({
          alpha: true,
          antialias: true,
        });
        renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
        renderer.outputColorSpace = three.SRGBColorSpace;
        applyMatch3DRendererCanvasLayout(renderer.domElement);
        container.appendChild(renderer.domElement);
        const handleContextLost = (event: Event) => {
          event.preventDefault();
          onFallback();
        };
        renderer.domElement.addEventListener(
          'webglcontextlost',
          handleContextLost,
          false,
        );

        const scene = new three.Scene();
        scene.background = null;
        const camera = new three.OrthographicCamera(
          -4.4,
          4.4,
          MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE,
          -MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE,
          0.1,
          40,
        );
        camera.position.set(4.1, 5.4, 4.45);
        camera.lookAt(0, 0, 0);

        scene.add(new three.AmbientLight(0xffffff, 0.82));
        const keyLight = new three.DirectionalLight(0xffffff, 3.1);
        keyLight.position.set(-3.4, 5.2, 3.8);
        scene.add(keyLight);
        const fillLight = new three.DirectionalLight(0xfef3c7, 0.55);
        fillLight.position.set(3.2, 2.4, -3.2);
        scene.add(fillLight);
        const rimLight = new three.DirectionalLight(0xffffff, 0.75);
        rimLight.position.set(1.2, 2.2, -4.4);
        scene.add(rimLight);

        const resize = () => {
          const rect = container.getBoundingClientRect();
          const width = Math.max(1, rect.width);
          const height = Math.max(1, rect.height);
          const aspect = width / height;
          camera.top = MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE;
          camera.bottom = -MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE;
          camera.left = -MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE * aspect;
          camera.right = MATCH3D_TRAY_CAMERA_VERTICAL_HALF_SIZE * aspect;
          camera.updateProjectionMatrix();
          renderer.setSize(width, height, false);
          relayoutTrayPreviewEntries({
            animationId: null,
            camera,
            entries: runtimeRef.current?.entries ?? new Map(),
            failedGeneratedModelTypeIds:
              runtimeRef.current?.failedGeneratedModelTypeIds ?? new Set(),
            generatedModelTemplates:
              runtimeRef.current?.generatedModelTemplates ?? new Map(),
            renderer,
            scene,
            three,
          });
          renderer.render(scene, camera);
        };
        resize();

        const ro = new ResizeObserver(resize);
        ro.observe(container);

        const animate = () => {
          const activeRuntime = runtimeRef.current;
          if (!activeRuntime) {
            return;
          }
          renderer.render(scene, camera);
          activeRuntime.animationId = window.requestAnimationFrame(animate);
        };
        runtimeRef.current = {
          animationId: window.requestAnimationFrame(animate),
          camera,
          entries: new Map(),
          failedGeneratedModelTypeIds: new Set(),
          generatedModelTemplates: new Map(),
          renderer,
          scene,
          three,
        };
        setReady(true);

        cleanupResize = () => {
          renderer.domElement.removeEventListener(
            'webglcontextlost',
            handleContextLost,
            false,
          );
          ro.disconnect();
        };
      } catch {
        onFallback();
      }
    }

    void setup();

    return () => {
      cancelled = true;
      cleanupResize?.();
      disposeTrayPreview(runtimeRef.current);
      runtimeRef.current = null;
      setReady(false);
    };
  }, [onFallback]);

  useEffect(() => {
    const runtime = runtimeRef.current;
    if (!runtime) {
      return undefined;
    }
    const abortController = new AbortController();
    const staleItemTypeIds = new Set(runtime.generatedModelTemplates.keys());
    trayModelSourceByType.forEach((source, itemTypeId) => {
      staleItemTypeIds.delete(itemTypeId);
      const hadFreshTemplate =
        runtime.generatedModelTemplates.get(itemTypeId)?.source === source;
      runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
      void loadMatch3DGeneratedModelTemplate(
        runtime.generatedModelTemplates,
        runtime.three,
        itemTypeId,
        source,
        abortController.signal,
      )
        .then(() => {
          if (hadFreshTemplate) {
            return;
          }
          setTrayModelRevision((current) => current + 1);
          runtime.entries.forEach((mesh, itemInstanceId) => {
            const itemType = slotItems.find(
              (item) => item?.itemInstanceId === itemInstanceId,
            )?.itemTypeId;
            if (itemType !== itemTypeId) {
              return;
            }
            runtime.scene.remove(mesh);
            disposeThreeObject(mesh);
            runtime.entries.delete(itemInstanceId);
          });
        })
        .catch((caughtError) => {
          if (abortController.signal.aborted) {
            return;
          }
          warnMatch3DGeneratedModelLoadFailure(itemTypeId, source, caughtError);
          runtime.generatedModelTemplates.delete(itemTypeId);
          runtime.failedGeneratedModelTypeIds.add(itemTypeId);
          setTrayModelRevision((current) => current + 1);
        });
    });
    staleItemTypeIds.forEach((itemTypeId) => {
      const template = runtime.generatedModelTemplates.get(itemTypeId);
      if (template) {
        disposeThreeObject(template.scene);
      }
      runtime.generatedModelTemplates.delete(itemTypeId);
      runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
    });

    return () => {
      abortController.abort();
    };
  }, [slotItems, trayModelSignature, trayModelSourceByType]);

  useEffect(() => {
    const runtime = runtimeRef.current;
    if (!runtime) {
      return;
    }
    const activeIds = new Set(
      slotItems
        .filter((item): item is Match3DItemSnapshot => Boolean(item))
        .map((item) => item.itemInstanceId),
    );

    runtime.entries.forEach((mesh, itemInstanceId) => {
      if (!activeIds.has(itemInstanceId)) {
        runtime.scene.remove(mesh);
        disposeThreeObject(mesh);
        runtime.entries.delete(itemInstanceId);
      }
    });

    const referenceMaxDimension = resolveMatch3DTrayPreviewReferenceDimension(
      runtime.three,
      referenceItems.length > 0
        ? referenceItems
        : slotItems.filter((item): item is Match3DItemSnapshot =>
            Boolean(item),
          ),
    );

    slotItems.forEach((item, slotIndex) => {
      if (!item) {
        return;
      }
      const previewSignature = buildTrayPreviewSignature(
        item,
        referenceMaxDimension,
        trayModelSourceByType.get(item.itemTypeId) ?? '',
        trayModelRevision,
      );
      let mesh = runtime.entries.get(item.itemInstanceId);
      if (mesh && mesh.userData.trayPreviewSignature !== previewSignature) {
        runtime.scene.remove(mesh);
        disposeThreeObject(mesh);
        runtime.entries.delete(item.itemInstanceId);
        mesh = undefined;
      }
      if (!mesh) {
        const preview = createItemMesh(
          runtime.three,
          item,
          runtime.failedGeneratedModelTypeIds.has(item.itemTypeId)
            ? null
            : runtime.generatedModelTemplates,
        );
        const model = preview.mesh;
        const rotation = resolveMatch3DTrayPreviewRotation(item.visualKey);
        model.rotation.set(rotation.x, rotation.y, rotation.z);

        // 中文注释：模型先在自身 pivot 内居中，再把 pivot 放进对应格子，避免非对称积木偏出 UI 栏。
        const itemBounds = new runtime.three.Box3().setFromObject(model);
        const itemSize = itemBounds.getSize(new runtime.three.Vector3());
        const itemDimension = Math.max(
          itemSize.x,
          itemSize.y,
          itemSize.z,
          0.001,
        );
        model.scale.multiplyScalar(
          resolveMatch3DTrayPreviewScale(itemDimension, referenceMaxDimension),
        );
        const centeredBounds = new runtime.three.Box3().setFromObject(model);
        const center = centeredBounds.getCenter(new runtime.three.Vector3());
        model.position.sub(center);
        mesh = new runtime.three.Group();
        mesh.add(model);
        mesh.userData.trayPreviewSignature = previewSignature;
        runtime.scene.add(mesh);
        runtime.entries.set(item.itemInstanceId, mesh);
      }
      const activeMesh = mesh;
      activeMesh.userData.traySlotIndex = slotIndex;
      positionTrayPreviewObject(runtime, activeMesh, slotIndex);
    });

    runtime.renderer.render(runtime.scene, runtime.camera);
  }, [
    ready,
    referenceItems,
    slotItems,
    trayModelRevision,
    trayModelSignature,
    trayModelSourceByType,
  ]);

  return (
    <div
      ref={containerRef}
      className="pointer-events-none absolute inset-0 z-10"
      data-testid="match3d-tray-model-board"
    />
  );
}

export function Match3DPhysicsBoard({
  run,
  generatedItemAssets = [],
  disabled,
  onClickItem,
  onFallback,
}: Match3DPhysicsBoardProps) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const runtimeRef = useRef<PhysicsRuntime | null>(null);
  const disabledRef = useRef(disabled);
  const fallbackRef = useRef(onFallback);
  const runRef = useRef(run);
  const pointerSelectionRef = useRef<Match3DPhysicsPointerSelection | null>(
    null,
  );
  const generatedModelByType = useMemo(
    () => buildMatch3DGeneratedAssetTypeMap(run, generatedItemAssets),
    [generatedItemAssets, run],
  );
  const generatedModelSignature = useMemo(
    () => buildGeneratedModelMapSignature(generatedModelByType),
    [generatedModelByType],
  );
  const [ready, setReady] = useState(false);
  const [generatedModelRevision, setGeneratedModelRevision] = useState(0);

  useEffect(() => {
    fallbackRef.current = onFallback;
  }, [onFallback]);

  useEffect(() => {
    disabledRef.current = disabled;
  }, [disabled]);

  useEffect(() => {
    runRef.current = run;
  }, [run]);

  useEffect(() => {
    let cancelled = false;

    async function setup() {
      const container = containerRef.current;
      if (!container || !hasWebGLSupport()) {
        fallbackRef.current();
        return;
      }

      try {
        const [three, cannon] = await Promise.all([
          import('three'),
          import('cannon-es'),
        ]);
        if (cancelled || !containerRef.current) {
          return;
        }

        const renderer = new three.WebGLRenderer({
          alpha: true,
          antialias: true,
        });
        renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.8));
        renderer.shadowMap.enabled = true;
        renderer.outputColorSpace = three.SRGBColorSpace;
        applyMatch3DRendererCanvasLayout(renderer.domElement);
        container.appendChild(renderer.domElement);
        const handleContextLost = (event: Event) => {
          event.preventDefault();
          fallbackRef.current();
        };
        renderer.domElement.addEventListener(
          'webglcontextlost',
          handleContextLost,
          false,
        );

        const scene = new three.Scene();
        scene.background = null;

        const camera = new three.OrthographicCamera(
          -MATCH3D_CAMERA_HALF_SIZE,
          MATCH3D_CAMERA_HALF_SIZE,
          MATCH3D_CAMERA_HALF_SIZE,
          -MATCH3D_CAMERA_HALF_SIZE,
          0.1,
          80,
        );
        camera.position.set(0, 17.5, 0.01);
        camera.lookAt(0, 0, 0);

        const ambient = new three.AmbientLight(0xffffff, 1.28);
        scene.add(ambient);
        const keyLight = new three.DirectionalLight(0xffffff, 2.35);
        keyLight.position.set(-3.5, 10, 3.2);
        keyLight.castShadow = true;
        scene.add(keyLight);
        const fillLight = new three.DirectionalLight(0xfef3c7, 1.05);
        fillLight.position.set(4, 6, -4.5);
        scene.add(fillLight);

        const floor = new three.Mesh(
          new three.CircleGeometry(MATCH3D_POT_FLOOR_RADIUS, 112),
          new three.MeshStandardMaterial({
            color: '#d89943',
            metalness: 0.05,
            roughness: 0.72,
          }),
        );
        floor.rotation.x = -Math.PI / 2;
        floor.receiveShadow = true;
        scene.add(floor);

        const basinShade = new three.Mesh(
          new three.RingGeometry(
            MATCH3D_POT_INNER_RADIUS * 0.72,
            MATCH3D_POT_FLOOR_RADIUS,
            112,
          ),
          new three.MeshBasicMaterial({
            color: '#8a4f1f',
            opacity: 0.2,
            side: three.DoubleSide,
            transparent: true,
          }),
        );
        basinShade.rotation.x = -Math.PI / 2;
        basinShade.position.y = 0.012;
        scene.add(basinShade);

        const potWall = new three.Mesh(
          new three.CylinderGeometry(
            MATCH3D_POT_OUTER_RADIUS,
            MATCH3D_POT_FLOOR_RADIUS,
            MATCH3D_POT_WALL_HEIGHT,
            112,
            1,
            true,
          ),
          new three.MeshStandardMaterial({
            color: '#b76d2b',
            metalness: 0.08,
            opacity: 0.46,
            roughness: 0.64,
            side: three.DoubleSide,
            transparent: true,
          }),
        );
        potWall.position.y = MATCH3D_POT_WALL_HEIGHT / 2;
        potWall.receiveShadow = true;
        scene.add(potWall);

        const innerRim = new three.Mesh(
          new three.TorusGeometry(MATCH3D_POT_INNER_RADIUS, 0.08, 10, 112),
          new three.MeshStandardMaterial({
            color: '#f7dd9c',
            metalness: 0.08,
            roughness: 0.5,
          }),
        );
        innerRim.rotation.x = Math.PI / 2;
        innerRim.position.y = MATCH3D_POT_WALL_HEIGHT + 0.035;
        scene.add(innerRim);

        const rim = new three.Mesh(
          new three.TorusGeometry(MATCH3D_POT_OUTER_RADIUS, 0.22, 12, 112),
          new three.MeshStandardMaterial({
            color: '#f1d38e',
            metalness: 0.1,
            roughness: 0.52,
          }),
        );
        rim.rotation.x = Math.PI / 2;
        rim.position.y = MATCH3D_POT_WALL_HEIGHT + 0.1;
        scene.add(rim);

        const stabilityPlan = resolveMatch3DPhysicsStabilityPlan(
          runRef.current.totalItemCount,
        );
        const spawnTimingPlan = resolveMatch3DSpawnTimingPlan(
          runRef.current.totalItemCount,
        );
        const world = new cannon.World({
          gravity: new cannon.Vec3(0, -6.2, 0),
        });
        world.allowSleep = true;
        world.broadphase = new cannon.SAPBroadphase(world);
        world.defaultContactMaterial.friction = stabilityPlan.contactFriction;
        world.defaultContactMaterial.restitution =
          stabilityPlan.contactRestitution;
        const solver = world.solver as import('cannon-es').GSSolver;
        solver.iterations = stabilityPlan.solverIterations;
        solver.tolerance = stabilityPlan.solverTolerance;

        const floorBody = new cannon.Body({
          mass: 0,
          shape: new cannon.Plane(),
        });
        floorBody.quaternion.setFromEuler(-Math.PI / 2, 0, 0);
        world.addBody(floorBody);

        const wallSegments = 56;
        for (let index = 0; index < wallSegments; index += 1) {
          const angle = (index / wallSegments) * Math.PI * 2;
          const x = Math.cos(angle) * (MATCH3D_POT_INNER_RADIUS + 0.18);
          const z = Math.sin(angle) * (MATCH3D_POT_INNER_RADIUS + 0.18);
          const wall = new cannon.Body({
            mass: 0,
            shape: new cannon.Box(
              new cannon.Vec3(0.22, MATCH3D_POT_WALL_HEIGHT, 0.34),
            ),
            position: new cannon.Vec3(x, MATCH3D_POT_WALL_HEIGHT, z),
          });
          wall.quaternion.setFromEuler(0, -angle, 0);
          world.addBody(wall);
        }

        const runtime: PhysicsRuntime = {
          animationId: null,
          camera,
          entries: new Map(),
          failedGeneratedModelTypeIds: new Set(),
          generatedModelByType,
          generatedModelTemplates: new Map(),
          pendingSpawns: new Map(),
          raycaster: new three.Raycaster(),
          renderer,
          scene,
          spawnTimingPlan,
          stabilityPlan,
          world,
          three,
          cannon,
        };
        runtimeRef.current = runtime;

        const resize = () => {
          const rect = container.getBoundingClientRect();
          const size = Math.max(1, Math.min(rect.width, rect.height));
          renderer.setSize(size, size, false);
          camera.left = -MATCH3D_CAMERA_HALF_SIZE;
          camera.right = MATCH3D_CAMERA_HALF_SIZE;
          camera.top = MATCH3D_CAMERA_HALF_SIZE;
          camera.bottom = -MATCH3D_CAMERA_HALF_SIZE;
          camera.updateProjectionMatrix();
        };
        resize();

        const ro = new ResizeObserver(resize);
        ro.observe(container);

        let lastTime = performance.now();
        const animate = (now: number) => {
          const activeRuntime = runtimeRef.current;
          if (!activeRuntime) {
            return;
          }
          const delta = Math.min(
            0.04,
            Math.max(0.001, (now - lastTime) / 1000),
          );
          lastTime = now;
          flushPendingPhysicsSpawns(activeRuntime, now);
          activeRuntime.entries.forEach((entry) => {
            applyCenterGravity(entry);
            applyDynamicStackLift(entry);
            applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan);
            constrainBodyInsidePot(entry);
          });
          activeRuntime.world.step(MATCH3D_PHYSICS_STEP, delta, 5);

          activeRuntime.entries.forEach((entry) => {
            applyCenterGravity(entry);
            applyDynamicStackLift(entry);
            applyStabilityPlanToBody(entry, activeRuntime.stabilityPlan);
            constrainBodyInsidePot(entry);
            const spawnProgress = resolveSpawnAnimationProgress(entry, now);
            const spawnScale = resolveMatch3DSpawnVisualScale(spawnProgress);
            const selectedScale =
              pointerSelectionRef.current?.itemInstanceId ===
              entry.item.itemInstanceId
                ? MATCH3D_SELECTED_MODEL_SCALE
                : 1;
            entry.mesh.scale
              .copy(entry.baseVisualScale)
              .multiplyScalar(spawnScale * selectedScale);
            entry.mesh.position.set(
              entry.body.position.x,
              entry.body.position.y -
                (1 - spawnProgress) * MATCH3D_ITEM_SPAWN_VISUAL_DROP_OFFSET,
              entry.body.position.z,
            );
            entry.mesh.quaternion.set(
              entry.lockReadableTop ? 0 : entry.body.quaternion.x,
              entry.lockReadableTop ? 0 : entry.body.quaternion.y,
              entry.lockReadableTop ? 0 : entry.body.quaternion.z,
              entry.lockReadableTop ? 1 : entry.body.quaternion.w,
            );
            if (entry.lockReadableTop) {
              entry.mesh.rotation.y = entry.topRotationY;
            }
          });

          activeRuntime.renderer.render(
            activeRuntime.scene,
            activeRuntime.camera,
          );
          activeRuntime.animationId = window.requestAnimationFrame(animate);
        };
        runtime.animationId = window.requestAnimationFrame(animate);
        setReady(true);

        return () => {
          renderer.domElement.removeEventListener(
            'webglcontextlost',
            handleContextLost,
            false,
          );
          ro.disconnect();
        };
      } catch {
        fallbackRef.current();
      }
    }

    let cleanupResize: (() => void) | undefined;
    void setup().then((cleanup) => {
      cleanupResize = cleanup;
    });

    return () => {
      cancelled = true;
      cleanupResize?.();
      disposeRuntime(runtimeRef.current);
      runtimeRef.current = null;
    };
  }, []);

  useEffect(() => {
    const runtime = runtimeRef.current;
    if (!runtime) {
      return undefined;
    }
    runtime.generatedModelByType = generatedModelByType;
    const abortController = new AbortController();
    const staleItemTypeIds = new Set(runtime.generatedModelTemplates.keys());
    generatedModelByType.forEach((asset, itemTypeId) => {
      const source = normalizeMatch3DGeneratedModelSource(asset);
      staleItemTypeIds.delete(itemTypeId);
      if (!source) {
        return;
      }
      const hadFreshTemplate =
        runtime.generatedModelTemplates.get(itemTypeId)?.source === source;
      runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
      void loadMatch3DGeneratedModelTemplate(
        runtime.generatedModelTemplates,
        runtime.three,
        itemTypeId,
        source,
        abortController.signal,
      )
        .then(() => {
          if (hadFreshTemplate) {
            return;
          }
          setGeneratedModelRevision((current) => current + 1);
          const hasActiveEntry = [...runtime.entries.values()].some(
            (entry) => entry.item.itemTypeId === itemTypeId,
          );
          if (!hasActiveEntry) {
            return;
          }
          runtime.entries.forEach((entry, itemInstanceId) => {
            if (entry.item.itemTypeId === itemTypeId) {
              removePhysicsEntry(runtime, itemInstanceId, entry);
            }
          });
        })
        .catch((caughtError) => {
          if (abortController.signal.aborted) {
            return;
          }
          warnMatch3DGeneratedModelLoadFailure(itemTypeId, source, caughtError);
          runtime.generatedModelTemplates.delete(itemTypeId);
          runtime.failedGeneratedModelTypeIds.add(itemTypeId);
          setGeneratedModelRevision((current) => current + 1);
        });
    });
    staleItemTypeIds.forEach((itemTypeId) => {
      const template = runtime.generatedModelTemplates.get(itemTypeId);
      if (template) {
        disposeThreeObject(template.scene);
      }
      runtime.generatedModelTemplates.delete(itemTypeId);
      runtime.failedGeneratedModelTypeIds.delete(itemTypeId);
    });

    return () => {
      abortController.abort();
    };
  }, [generatedModelByType, generatedModelSignature]);

  useEffect(() => {
    const runtime = runtimeRef.current;
    if (!runtime) {
      return;
    }

    const activeItemIds = new Set(
      run.items
        .filter((item) => isItemState(item.state, 'in_board'))
        .map((item) => item.itemInstanceId),
    );

    runtime.entries.forEach((entry, itemInstanceId) => {
      if (!activeItemIds.has(itemInstanceId)) {
        removePhysicsEntry(runtime, itemInstanceId, entry);
      }
    });

    runtime.pendingSpawns.forEach((pendingSpawn, itemInstanceId) => {
      if (!activeItemIds.has(itemInstanceId)) {
        runtime.pendingSpawns.delete(itemInstanceId);
      }
    });

    syncRuntimeStabilityPlan(runtime, run.totalItemCount);
    runtime.spawnTimingPlan = resolveMatch3DSpawnTimingPlan(run.totalItemCount);
    const stackHeightPlan = buildMatch3DStackHeightTargets(run);

    run.items.forEach((item) => {
      if (!isItemState(item.state, 'in_board')) {
        return;
      }

      const renderSignature = buildMatch3DPhysicsEntrySignature(
        run.runId,
        item,
        resolveGeneratedModelSourceForItemType(
          generatedModelByType,
          item.itemTypeId,
        ),
        generatedModelRevision,
      );
      const existing = runtime.entries.get(item.itemInstanceId);
      if (existing) {
        if (existing.renderSignature !== renderSignature) {
          // 中文注释：后端重开局时 itemInstanceId 可能复用，旧 3D 模型必须随当前 run 快照重建。
          removePhysicsEntry(runtime, item.itemInstanceId, existing);
        } else {
          existing.item = item;
          existing.targetY =
            stackHeightPlan.targets.get(item.itemInstanceId)?.targetY ??
            existing.body.position.y;
          existing.mesh.visible = true;
          return;
        }
      }

      const existingPending = runtime.pendingSpawns.get(item.itemInstanceId);
      if (existingPending) {
        if (existingPending.renderSignature !== renderSignature) {
          runtime.pendingSpawns.delete(item.itemInstanceId);
        } else {
          existingPending.item = item;
          existingPending.layerCapacity = stackHeightPlan.layerCapacity;
          existingPending.targetY =
            stackHeightPlan.targets.get(item.itemInstanceId)?.targetY ??
            existingPending.targetY;
          return;
        }
      }

      const stackTarget = stackHeightPlan.targets.get(item.itemInstanceId);
      const spawnAtMs =
        performance.now() +
        runtime.spawnTimingPlan.initialDelayMs +
        resolveMatch3DSpawnDelay(
          stackTarget?.activeLayerRank ?? item.layer - 1,
          stackHeightPlan.layerCapacity,
          runtime.spawnTimingPlan,
        );
      runtime.pendingSpawns.set(item.itemInstanceId, {
        activeLayerRank: stackTarget?.activeLayerRank ?? item.layer - 1,
        item,
        layerCapacity: stackHeightPlan.layerCapacity,
        renderSignature,
        spawnAtMs,
        targetY:
          stackTarget?.targetY ??
          resolveMatch3DStackTargetY(run.totalItemCount, activeItemIds.size, 0),
      });
    });
  }, [
    generatedModelSignature,
    generatedModelRevision,
    generatedModelByType,
    ready,
    run,
    run.items,
    run.runId,
    run.snapshotVersion,
  ]);

  const resolvePointerHitItemId = (event: PointerEvent<HTMLDivElement>) => {
    const runtime = runtimeRef.current;
    const container = containerRef.current;
    if (!runtime || !container || disabledRef.current) {
      return null;
    }
    return resolveMatch3DPhysicsHitItemId(
      runtime,
      container,
      event.clientX,
      event.clientY,
    );
  };

  const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
    event.stopPropagation();
    if (!runtimeRef.current || !containerRef.current || disabledRef.current) {
      return;
    }
    event.currentTarget.setPointerCapture?.(event.pointerId);
    pointerSelectionRef.current = {
      itemInstanceId: resolvePointerHitItemId(event),
      pointerId: event.pointerId,
    };
  };

  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
    if (pointerSelectionRef.current?.pointerId !== event.pointerId) {
      return;
    }
    pointerSelectionRef.current = {
      itemInstanceId: resolvePointerHitItemId(event),
      pointerId: event.pointerId,
    };
  };

  const handlePointerCancel = (event: PointerEvent<HTMLDivElement>) => {
    if (pointerSelectionRef.current?.pointerId !== event.pointerId) {
      return;
    }
    pointerSelectionRef.current = null;
    event.currentTarget.releasePointerCapture?.(event.pointerId);
  };

  const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => {
    event.stopPropagation();
    if (pointerSelectionRef.current?.pointerId !== event.pointerId) {
      return;
    }
    const itemInstanceId = resolvePointerHitItemId(event);
    pointerSelectionRef.current = null;
    event.currentTarget.releasePointerCapture?.(event.pointerId);
    const item = itemInstanceId
      ? runRef.current.items.find(
          (entry) => entry.itemInstanceId === itemInstanceId,
        )
      : null;
    if (item?.clickable && isItemState(item.state, 'in_board')) {
      onClickItem(item);
    }
  };

  return (
    <div
      ref={containerRef}
      className="absolute inset-0 z-10 overflow-visible"
      data-testid="match3d-physics-board"
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerCancel={handlePointerCancel}
      onPointerUp={handlePointerUp}
    >
      {!ready ? (
        <div className="absolute inset-0 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.18),transparent_28%)]" />
      ) : null}
    </div>
  );
}

export default Match3DPhysicsBoard;
