<template>
  <view class="three-map-container">
    <view ref="containerRef" class="three-canvas-wrapper"></view>

    <view v-if="isLoading && !loadError" class="loading-overlay">
      <view class="loading-content">
        <view class="loading-spinner"></view>
        <text class="loading-text">{{ loadingMessage }}</text>
        <view class="loading-progress">
          <view class="loading-bar" :style="{ width: `${loadingProgress}%` }"></view>
        </view>
      </view>
    </view>

    <view v-else-if="loadError" class="loading-overlay error-overlay">
      <view class="loading-content error-content">
        <text class="error-title">3D 模型加载失败</text>
        <text class="error-text">{{ loadingMessage }}</text>
        <view class="retry-btn" @tap="retryLoad">
          <text class="retry-text">重新加载</text>
        </view>
      </view>
    </view>

    <view v-if="showControls && floors.length" class="map-toolbar">
      <view class="overview-btn" :class="{ active: activeView === 'multi' }" @tap="showMultiFloor">
        <text class="overview-text">多层</text>
      </view>
    </view>

    <FloorSwitcher
      v-if="showControls && floors.length"
      :floors="floors"
      :current-floor="currentFloor"
      @floor-change="handleFloorChange"
    />

    <view v-if="showControls && selectedPOI" class="poi-detail-popup">
      <view class="poi-content">
        <text class="poi-name">{{ selectedPOI.name }}</text>
        <text class="poi-floor">{{ formatFloorLabel(selectedPOI.floorId) }} · {{ selectedPOI.primaryCategoryZh }}</text>
      </view>
    </view>
  </view>
</template>

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import * as THREE from 'three'
import { GLTFLoader, type GLTF } from 'three/addons/loaders/GLTFLoader.js'
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import FloorSwitcher from './FloorSwitcher.vue'
import type {
  GuideModelFloorAsset,
  GuideModelRenderPackage,
  GuideModelSource,
  GuideRenderPoi
} from '@/domain/guideModel'
import type {
  GuideRouteEndpoint,
  GuideRouteFloorSegment,
  GuideRouteResult
} from '@/domain/museum'

type ViewMode = 'overview' | 'floor' | 'multi'
type CameraPreset = 'top' | 'oblique'
type TouchGestureMode = 'orbit' | 'pan'
interface CameraFitOptions {
  distanceFactor?: number
  targetOffsetRatio?: THREE.Vector3
  screenOffsetRatio?: THREE.Vector2
  up?: THREE.Vector3
}

type FloorIndexItem = GuideModelFloorAsset

interface FloorOption {
  id: string
  label: string
}

type RenderPoi = GuideRenderPoi
type PoiDisplayMode = 'core' | 'balanced' | 'detail'
type PoiVisibilityTier = 'tight' | 'balanced' | 'full'

interface PoiSpriteUserData {
  poi?: RenderPoi
  baseScale?: number
  labelBaseScaleX?: number
  labelBaseScaleY?: number
  isPoiLabel?: boolean
  isPoiPulse?: boolean
  isPoiBase?: boolean
  isCorePoi?: boolean
}

interface FocusHallMaterialState {
  material: THREE.Material
  color?: THREE.Color
  emissive?: THREE.Color
  emissiveIntensity?: number
  opacity: number
  transparent: boolean
}

interface TargetPoiFocusRequest {
  requestId: number | string
  poiId: string
  name?: string
  floorId: string
  floorLabel?: string
  primaryCategoryZh?: string
  positionGltf?: [number, number, number]
  sourceObjectName?: string
}

interface TargetPoiFocusResult {
  requestId: number | string
  poiId: string
  floorId: string
  status: 'focused' | 'missing' | 'error'
  message?: string
}

interface MultiFloorModelItem {
  floor: FloorIndexItem
  label: string
  model: THREE.Object3D
  size: THREE.Vector3
}

interface ReusableModelResources {
  geometries: Set<THREE.BufferGeometry>
  materials: Set<THREE.Material>
  textures: Set<THREE.Texture>
}

interface PoiMarkerCacheEntry {
  floorId: string
  displayMode: PoiDisplayMode
  pois: RenderPoi[]
  group: THREE.Group
  markerSize: number
}

type ContainerRef = HTMLElement | { $el?: HTMLElement } | null

const props = withDefaults(defineProps<{
  assetBaseUrl?: string
  modelSource: GuideModelSource
  initialFloorId?: string
  initialView?: ViewMode
  showControls?: boolean
  showPoi?: boolean
  targetFocus?: TargetPoiFocusRequest | null
  touchGestureMode?: TouchGestureMode
  targetFocusDistanceFactor?: number
  routePreview?: GuideRouteResult | null
  showRoute?: boolean
  autoSwitch?: boolean
  autoSwitchThresholdLow?: number
  autoSwitchThresholdHigh?: number
  autoSwitchCooldown?: number
}>(), {
  assetBaseUrl: '',
  initialFloorId: 'L1',
  initialView: 'overview',
  showControls: true,
  showPoi: false,
  targetFocus: null,
  touchGestureMode: 'orbit',
  targetFocusDistanceFactor: 0.22,
  routePreview: null,
  showRoute: false,
  autoSwitch: true,
  autoSwitchThresholdLow: 1.45,
  autoSwitchThresholdHigh: 3.8,
  autoSwitchCooldown: 2000
})

const emit = defineEmits<{
  floorChange: [floorId: string]
  poiClick: [poi: RenderPoi]
  selectionClear: []
  targetFocus: [result: TargetPoiFocusResult]
  autoSwitch: [event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number }]
  autoSwitchBlocked: [reason: { reason: 'loading-locked' | 'cooldown' | 'disabled' }]
}>()

const containerRef = ref<ContainerRef>(null)
const isLoading = ref(true)
const loadError = ref(false)
const loadingProgress = ref(0)
const loadingMessage = ref('正在读取室内导览资源...')
const activeView = ref<ViewMode>(props.initialView)
const currentFloor = ref(props.initialFloorId)
const selectedPOI = ref<RenderPoi | null>(null)
const activeFocusPoiId = ref('')
const floorIndex = ref<FloorIndexItem[]>([])
const renderPackage = ref<GuideModelRenderPackage | null>(null)

const floors = computed<FloorOption[]>(() => (
  [...floorIndex.value]
    .sort((a, b) => b.order - a.order)
    .map((floor) => ({
      id: floor.floorId,
      label: formatFloorLabel(floor.floorId)
    }))
))
const shouldRenderPoiMarkers = computed(() => props.showPoi || props.showControls || Boolean(props.targetFocus))

let scene: THREE.Scene | null = null
let camera: THREE.PerspectiveCamera | null = null
let renderer: THREE.WebGLRenderer | null = null
let controls: OrbitControls | null = null
let loader: GLTFLoader | null = null
let dracoLoader: DRACOLoader | null = null
let activeModel: THREE.Object3D | null = null
let poiGroup: THREE.Group | null = null
let routeGroup: THREE.Group | null = null
let activeRoutePreviewSignature = ''
const poiDataCache = new Map<string, RenderPoi[]>()
const poiMarkerGroupCache = new Map<string, PoiMarkerCacheEntry>()
let activeFocusLabelSprite: THREE.Sprite | null = null
let activeFocusPulseSprite: THREE.Sprite | null = null
let activeFocusBaseSprite: THREE.Sprite | null = null
let activeFocusHallGlowMesh: THREE.Mesh | null = null
let activeFocusHallMaterialStates: FocusHallMaterialState[] = []
let animationId = 0
let resizeObserver: ResizeObserver | null = null
let isDisposed = false
let pendingTargetFocus: TargetPoiFocusRequest | null = null
let targetFocusQueue: Promise<unknown> = Promise.resolve()
let modelLoadVersion = 0
let pointerDownState: { x: number; y: number } | null = null
let cachedOverviewModel: THREE.Object3D | null = null
let cachedSharedModelUrl = ''
let autoSwitchOverviewMaxDim = 0

// 自动切换状态
let isAutoSwitchLocked = false
let lastAutoSwitchTime = 0
let lastControlDistance = 0
let floorAutoSwitchProtectedUntil = 0
let floorExitThresholdExceededSince = 0
let floorExitZoomOutAccumulatedDistance = 0
let autoSwitchTemporarilyDisabled = false
let autoSwitchDisableTimer: ReturnType<typeof setTimeout> | null = null
let isProgrammaticCameraChange = false
let programmaticCameraTimer: ReturnType<typeof setTimeout> | null = null

const floorAutoSwitchProtectionMs = 1600
const floorExitZoomOutIntentMinDeltaRatio = 0.01
const floorExitZoomOutAccumulatedRatio = 0.12
const floorExitThresholdHoldMs = 650

const syncControlInteractionOptions = () => {
  if (!controls) return

  const shouldEnablePan = props.showControls || props.touchGestureMode === 'pan'
  controls.enablePan = shouldEnablePan
  controls.touches.ONE = props.touchGestureMode === 'pan' ? THREE.TOUCH.PAN : THREE.TOUCH.ROTATE
  controls.touches.TWO = THREE.TOUCH.DOLLY_PAN
}

const corePoiCategories = new Set([
  'basic_service_facility',
  'transport_circulation',
  'accessibility_special_service'
])

const poiCategoryPriority: Record<string, number> = {
  target_preview: 100,
  accessibility_special_service: 88,
  basic_service_facility: 82,
  transport_circulation: 76,
  touring_poi: 58,
  operation_experience: 46
}

const poiGlyphMap: Record<string, string> = {
  accessible_restroom: '无',
  accessibility: '无',
  restroom: '卫',
  water: '水',
  rest_area: '休',
  elevator: '梯',
  stair: '梯',
  experience: '展',
  theater: '演',
  target_preview: '位'
}

const getContainerElement = () => {
  const container = containerRef.value
  if (!container) return null

  if (typeof HTMLElement === 'undefined') return null

  if (container instanceof HTMLElement) {
    return container
  }

  const element = '$el' in container ? container.$el : null
  return element instanceof HTMLElement ? element : null
}

const formatFloorLabel = (floorId: string) => (
  floorIndex.value.find((floor) => floor.floorId === floorId)?.label || floorId
)

const floorExteriorNameKeywords = [
  '外墙',
  '玻璃幕墙',
  '楼顶',
  '屋顶',
  '室外'
]

const isRenderableObject = (object: THREE.Object3D) => (
  object instanceof THREE.Mesh
  || object instanceof THREE.Line
  || object instanceof THREE.Points
)

const getModelNodeNames = (object: THREE.Object3D) => {
  const names: string[] = []
  let current: THREE.Object3D | null = object

  while (current) {
    if (current.name) {
      names.push(current.name)
    }

    current = current.parent
  }

  return names
}

const normalizeModelMatchKey = (value: string) => (
  value
    .trim()
    .toLowerCase()
    .replace(/[\s_\-./\\]/g, '')
)

const getPoiModelMatchKeys = (poi: RenderPoi) => (
  [
    poi.sourceObjectName,
    `${poi.floorId}_${poi.name}`,
    poi.name
  ]
    .filter((value): value is string => Boolean(value))
    .map(normalizeModelMatchKey)
    .filter(Boolean)
)

const isPoiModelNodeMatch = (object: THREE.Object3D, poi: RenderPoi) => {
  const keys = getPoiModelMatchKeys(poi)
  if (!keys.length) return false

  return getModelNodeNames(object)
    .map(normalizeModelMatchKey)
    .some((name) => keys.some((key) => name === key || name.includes(key)))
}

const findPoiModelRoot = (poi: RenderPoi): THREE.Object3D | null => {
  if (!activeModel) return null

  let matchedRoot: THREE.Object3D | null = null

  activeModel.traverse((child) => {
    if (matchedRoot || child === activeModel) return

    const childName = child.name ? normalizeModelMatchKey(child.name) : ''
    const keys = getPoiModelMatchKeys(poi)
    if (childName && keys.some((key) => childName === key || childName.includes(key))) {
      matchedRoot = child
    }
  })

  if (matchedRoot) return matchedRoot

  activeModel.traverse((child) => {
    if (matchedRoot || !(child instanceof THREE.Mesh) || !child.visible) return

    if (isPoiModelNodeMatch(child, poi)) {
      matchedRoot = child.parent && child.parent !== activeModel ? child.parent : child
    }
  })

  return matchedRoot
}

const isExteriorModelNode = (object: THREE.Object3D) => (
  getModelNodeNames(object).some((name) => (
    floorExteriorNameKeywords.some((keyword) => name.includes(keyword))
  ))
)

const getKnownFloorMatchEntriesBySpecificity = () => (
  floorIndex.value
    .flatMap((floor) => [floor.floorId, floor.label, ...(floor.modelMatchKeys || [])]
      .filter((value): value is string => Boolean(value))
      .map((key) => ({ key, floorId: floor.floorId })))
    .sort((a, b) => b.key.length - a.key.length)
)

const getModelNodeFloorIdByName = (name: string) => {
  if (!name) return ''

  const normalizedName = normalizeModelMatchKey(name)
  return getKnownFloorMatchEntriesBySpecificity().find(({ key }) => (
    name === key
    || name.startsWith(`${key}_`)
    || name.startsWith(`${key}中心`)
    || normalizedName === normalizeModelMatchKey(key)
    || normalizedName.startsWith(normalizeModelMatchKey(key))
  ))?.floorId || ''
}

const getModelNodeFloorId = (object: THREE.Object3D) => (
  getModelNodeNames(object)
    .map(getModelNodeFloorIdByName)
    .find(Boolean) || ''
)

const setModelNodeVisibility = (model: THREE.Object3D, isVisible: (object: THREE.Object3D) => boolean) => {
  model.traverse((child) => {
    child.visible = child === model || !isRenderableObject(child) || isVisible(child)
  })
}

const showAllModelNodes = (model: THREE.Object3D) => {
  model.traverse((child) => {
    child.visible = true
  })
}

const showOnlyFloorModelNodes = (model: THREE.Object3D, floorId: string) => {
  let currentFloorRenderableCount = 0

  model.traverse((child) => {
    if (!isRenderableObject(child)) return

    const nodeFloorId = getModelNodeFloorId(child)
    if (nodeFloorId === floorId && !isExteriorModelNode(child)) {
      currentFloorRenderableCount += 1
    }
  })

  if (!currentFloorRenderableCount) {
    setModelNodeVisibility(model, (child) => !isExteriorModelNode(child))
    return
  }

  setModelNodeVisibility(model, (child) => (
    getModelNodeFloorId(child) === floorId
    && !isExteriorModelNode(child)
  ))
}

const applyModelVisibilityForView = (
  model: THREE.Object3D,
  view: ViewMode,
  floorId?: string
) => {
  if (view === 'floor' && floorId) {
    showOnlyFloorModelNodes(model, floorId)
    return
  }

  showAllModelNodes(model)
}

const getObjectBox = (object: THREE.Object3D) => {
  const box = new THREE.Box3()

  object.updateWorldMatrix(true, true)
  object.traverse((child) => {
    if (!child.visible || !isRenderableObject(child)) return

    const childBox = new THREE.Box3().setFromObject(child)
    if (!childBox.isEmpty()) {
      box.union(childBox)
    }
  })

  if (box.isEmpty()) {
    return new THREE.Box3().setFromObject(object)
  }

  return box
}

const getPoiDisplayMode = (): PoiDisplayMode => {
  if (activeView.value === 'overview') return 'core'
  if (activeView.value === 'multi') return 'balanced'
  return 'detail'
}

const getPoiMarkerCacheKey = (floorId: string, displayMode = getPoiDisplayMode()) => `${floorId}:${displayMode}`

const getPoiGlyph = (poi: RenderPoi) => (
  poiGlyphMap[poi.iconType] || poi.name.charAt(0) || '点'
)

const shouldShowPoiInCurrentMode = (poi: RenderPoi) => {
  const mode = getPoiDisplayMode()

  if (mode === 'core') {
    return corePoiCategories.has(poi.primaryCategory)
  }

  if (mode === 'balanced') {
    return corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'touring_poi'
  }

  return true
}

const getPoiSpriteUserData = (sprite: THREE.Sprite) => sprite.userData as PoiSpriteUserData

const isPoiAuxiliarySprite = (sprite: THREE.Sprite) => (
  Boolean(sprite.userData.isPoiLabel || sprite.userData.isPoiPulse || sprite.userData.isPoiBase)
)

const getActiveModelSpan = () => {
  if (!activeModel) return 1

  const size = getObjectSize(activeModel)
  return Math.max(size.x, size.y, size.z, 1)
}

const getPoiVisibilityTier = (): PoiVisibilityTier => {
  if (activeView.value === 'floor') return 'full'
  if (!controls || !activeModel) return 'full'

  const ratio = controls.getDistance() / getActiveModelSpan()

  if (ratio >= 0.32) return 'tight'
  if (ratio >= 0.2) return 'balanced'
  return 'full'
}

const shouldShowPoiAtDistance = (poi: RenderPoi, tier: PoiVisibilityTier) => {
  if (poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview') {
    return true
  }

  if (tier === 'tight') {
    return corePoiCategories.has(poi.primaryCategory)
  }

  if (tier === 'balanced') {
    return corePoiCategories.has(poi.primaryCategory) || poi.primaryCategory === 'touring_poi'
  }

  return true
}

const getPoiVisibilityLimit = (tier: PoiVisibilityTier) => {
  if (tier === 'tight') return 5
  if (tier === 'balanced') return 9
  return Number.POSITIVE_INFINITY
}

const getPoiScreenSpacing = (tier: PoiVisibilityTier) => {
  if (tier === 'tight') return 116
  if (tier === 'balanced') return 78
  return 0
}

const getPoiPriority = (poi: RenderPoi) => {
  const categoryPriority = poiCategoryPriority[poi.primaryCategory] || 20
  const selectedBoost = poi.id === activeFocusPoiId.value ? 1000 : 0

  return selectedBoost + categoryPriority
}

const updatePoiVisibilityByDistance = () => {
  if (!poiGroup || !controls || !activeModel || !camera || !renderer) return

  const tier = getPoiVisibilityTier()
  const limit = getPoiVisibilityLimit(tier)
  const spacing = getPoiScreenSpacing(tier)
  const acceptedPositions: THREE.Vector2[] = []
  let visibleCount = 0

  const candidates = getPoiSprites()
    .map((sprite) => {
      const poi = sprite.userData.poi as RenderPoi | undefined
      const screenPosition = sprite.position.clone().project(camera!)

      return {
        sprite,
        poi,
        screenPosition: new THREE.Vector2(
          (screenPosition.x + 1) * renderer!.domElement.clientWidth * 0.5,
          (1 - screenPosition.y) * renderer!.domElement.clientHeight * 0.5
        )
      }
    })
    .sort((a, b) => (b.poi ? getPoiPriority(b.poi) : 0) - (a.poi ? getPoiPriority(a.poi) : 0))

  candidates.forEach(({ sprite, poi, screenPosition }) => {
    if (!poi) {
      sprite.visible = false
      return
    }

    const isSelected = poi.id === activeFocusPoiId.value || poi.primaryCategory === 'target_preview'
    const categoryVisible = shouldShowPoiAtDistance(poi, tier)
    const isTooClose = spacing > 0 && acceptedPositions.some((position) => position.distanceTo(screenPosition) < spacing)
    const exceedsLimit = Number.isFinite(limit) && visibleCount >= limit

    if (!categoryVisible || (!isSelected && (isTooClose || exceedsLimit))) {
      sprite.visible = false
      return
    }

    sprite.visible = true
    acceptedPositions.push(screenPosition)
    visibleCount += 1
  })
}

const refreshPoiVisibilityByDistance = () => {
  updatePoiVisibilityByDistance()
}

const handleControlChange = () => {
  updatePoiVisibilityByDistance()
  checkAutoSwitch()
}

const setProgress = (progress: number, message: string) => {
  loadingProgress.value = Math.min(100, Math.max(0, progress))
  loadingMessage.value = message
}

const staleModelLoadMessage = 'STALE_MODEL_LOAD'

const startModelLoad = () => {
  modelLoadVersion += 1
  return modelLoadVersion
}

const invalidateModelLoads = () => {
  modelLoadVersion += 1
}

const isCurrentModelLoad = (loadToken: number) => (
  loadToken === modelLoadVersion && !isDisposed && Boolean(scene)
)

const createStaleModelLoadError = () => new Error(staleModelLoadMessage)

const isStaleModelLoadError = (error: unknown) => (
  error instanceof Error && error.message === staleModelLoadMessage
)

const assertCurrentModelLoad = (loadToken: number, staleObject?: THREE.Object3D) => {
  if (isCurrentModelLoad(loadToken)) return

  if (staleObject) {
    disposeObject(staleObject)
  }

  throw createStaleModelLoadError()
}

const waitForContainer = async () => {
  await nextTick()

  for (let index = 0; index < 20; index += 1) {
    const container = getContainerElement()
    if (container && container.clientWidth > 0 && container.clientHeight > 0) {
      return container
    }

    await new Promise((resolve) => setTimeout(resolve, 50))
  }

  return getContainerElement()
}

const initThree = async () => {
  const container = await waitForContainer()
  if (!container) {
    throw new Error('3D 容器未就绪')
  }

  scene = new THREE.Scene()
  scene.background = new THREE.Color(0xf1f3ee)

  const width = Math.max(1, container.clientWidth)
  const height = Math.max(1, container.clientHeight)
  camera = new THREE.PerspectiveCamera(42, width / height, 0.1, 10000)
  camera.position.set(80, 80, 120)

  renderer = new THREE.WebGLRenderer({
    antialias: true,
    alpha: true,
    powerPreference: 'high-performance'
  })
  renderer.setSize(width, height)
  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2))
  renderer.outputColorSpace = THREE.SRGBColorSpace
  renderer.toneMapping = THREE.ACESFilmicToneMapping
  renderer.toneMappingExposure = 1.05
  container.appendChild(renderer.domElement)

  controls = new OrbitControls(camera, renderer.domElement)
  controls.enableDamping = true
  controls.dampingFactor = 0.08
  controls.enableZoom = true
  controls.minDistance = 6
  controls.maxDistance = 1200
  controls.minPolarAngle = 0.08
  controls.maxPolarAngle = Math.PI * 0.48
  syncControlInteractionOptions()

  loader = new GLTFLoader()
  dracoLoader = new DRACOLoader()
  dracoLoader.setDecoderPath('/static/three/draco/')
  loader.setDRACOLoader(dracoLoader)
  poiGroup = new THREE.Group()
  poiGroup.name = 'GuideModelPOI'
  scene.add(poiGroup)

  routeGroup = new THREE.Group()
  routeGroup.name = 'GuideModelRoute'
  scene.add(routeGroup)

  const ambientLight = new THREE.AmbientLight(0xffffff, 1.6)
  scene.add(ambientLight)

  const keyLight = new THREE.DirectionalLight(0xffffff, 2.2)
  keyLight.position.set(80, 120, 60)
  scene.add(keyLight)

  const fillLight = new THREE.DirectionalLight(0xdfeaff, 0.9)
  fillLight.position.set(-80, 60, -80)
  scene.add(fillLight)

  resizeObserver = new ResizeObserver(handleResize)
  resizeObserver.observe(container)
  container.addEventListener('pointerdown', handlePointerDown)
  container.addEventListener('pointerup', handlePointerUp)

  // 监听控制器变化，用于自动切换和点位可见层级更新
  if (controls) {
    controls.addEventListener('change', handleControlChange)
  }

  startRenderLoop()
}

const startRenderLoop = () => {
  const render = () => {
    if (isDisposed || !renderer || !scene || !camera) return

    controls?.update()
    updateFocusLabelScale()
    renderer.render(scene, camera)
    animationId = window.requestAnimationFrame(render)
  }

  render()
}

const handleResize = () => {
  const container = getContainerElement()
  if (!container || !camera || !renderer) return

  const width = Math.max(1, container.clientWidth)
  const height = Math.max(1, container.clientHeight)
  camera.aspect = width / height
  camera.updateProjectionMatrix()
  renderer.setSize(width, height)
}

const materialTextureKeys = [
  'map',
  'alphaMap',
  'aoMap',
  'bumpMap',
  'normalMap',
  'displacementMap',
  'roughnessMap',
  'metalnessMap',
  'emissiveMap',
  'specularMap',
  'envMap',
  'lightMap',
  'gradientMap',
  'matcap',
  'clearcoatMap',
  'clearcoatNormalMap',
  'clearcoatRoughnessMap',
  'sheenColorMap',
  'sheenRoughnessMap',
  'transmissionMap',
  'thicknessMap',
  'iridescenceMap',
  'iridescenceThicknessMap'
] as const

const collectMaterialTextures = (material: THREE.Material | null | undefined) => {
  const textures = new Set<THREE.Texture>()
  if (!material) return textures

  const materialRecord = material as unknown as Record<string, unknown>
  materialTextureKeys.forEach((key) => {
    const texture = materialRecord[key]
    if (texture instanceof THREE.Texture) {
      textures.add(texture)
    }
  })

  return textures
}

const disposeMaterialTextures = (
  material: THREE.Material,
  protectedResources: ReusableModelResources,
  disposedTextures: Set<THREE.Texture>
) => {
  collectMaterialTextures(material).forEach((texture) => {
    if (!protectedResources.textures.has(texture) && !disposedTextures.has(texture)) {
      texture.dispose()
      disposedTextures.add(texture)
    }
  })
}

const collectReusableModelResources = (object: THREE.Object3D | null): ReusableModelResources => {
  const geometries = new Set<THREE.BufferGeometry>()
  const materials = new Set<THREE.Material>()
  const textures = new Set<THREE.Texture>()

  object?.traverse((child) => {
    if (child instanceof THREE.Mesh || child instanceof THREE.Line) {
      if (child.geometry) {
        geometries.add(child.geometry)
      }

      const childMaterials = Array.isArray(child.material) ? child.material : [child.material]
      childMaterials.forEach((material) => {
        if (!material) return

        materials.add(material)
        collectMaterialTextures(material).forEach((texture) => textures.add(texture))
      })
    }

    if (child instanceof THREE.Sprite && child.material) {
      materials.add(child.material)
      collectMaterialTextures(child.material).forEach((texture) => textures.add(texture))
    }
  })

  return { geometries, materials, textures }
}

const disposeObject = (
  object: THREE.Object3D,
  protectedResources = collectReusableModelResources(null)
) => {
  const disposedGeometries = new Set<THREE.BufferGeometry>()
  const disposedMaterials = new Set<THREE.Material>()
  const disposedTextures = new Set<THREE.Texture>()

  object.traverse((child) => {
    if (child instanceof THREE.Mesh) {
      if (
        child.geometry
        && !protectedResources.geometries.has(child.geometry)
        && !disposedGeometries.has(child.geometry)
      ) {
        child.geometry.dispose()
        disposedGeometries.add(child.geometry)
      }
      const materials = Array.isArray(child.material) ? child.material : [child.material]
      materials.forEach((material) => {
        if (material && !protectedResources.materials.has(material) && !disposedMaterials.has(material)) {
          disposeMaterialTextures(material, protectedResources, disposedTextures)
          material.dispose()
          disposedMaterials.add(material)
        }
      })
    }

    if (child instanceof THREE.Sprite) {
      if (!protectedResources.materials.has(child.material) && !disposedMaterials.has(child.material)) {
        disposeMaterialTextures(child.material, protectedResources, disposedTextures)
        child.material.dispose()
        disposedMaterials.add(child.material)
      }
    }

    if (child instanceof THREE.Line) {
      if (
        child.geometry
        && !protectedResources.geometries.has(child.geometry)
        && !disposedGeometries.has(child.geometry)
      ) {
        child.geometry.dispose()
        disposedGeometries.add(child.geometry)
      }
      const materials = Array.isArray(child.material) ? child.material : [child.material]
      materials.forEach((material) => {
        if (material && !protectedResources.materials.has(material) && !disposedMaterials.has(material)) {
          disposeMaterialTextures(material, protectedResources, disposedTextures)
          material.dispose()
          disposedMaterials.add(material)
        }
      })
    }
  })
}

const isPoiMarkerSprite = (child: THREE.Object3D): child is THREE.Sprite => (
  child instanceof THREE.Sprite
  && Boolean(child.userData.poi)
  && !isPoiAuxiliarySprite(child)
)

const getPoiSprites = () => {
  const sprites: THREE.Sprite[] = []

  poiGroup?.traverse((child) => {
    if (isPoiMarkerSprite(child)) {
      sprites.push(child)
    }
  })

  return sprites
}

const detachPoiMarkerGroups = () => {
  poiMarkerGroupCache.forEach((entry) => {
    entry.group.parent?.remove(entry.group)
  })
}

const disposePoiMarkerCache = () => {
  detachPoiMarkerGroups()
  poiMarkerGroupCache.forEach((entry) => {
    disposeObject(entry.group)
  })
  poiMarkerGroupCache.clear()
  poiDataCache.clear()
}

const clearPoiGroupChildren = () => {
  if (!poiGroup) return

  while (poiGroup.children.length) {
    const child = poiGroup.children[0]
    poiGroup.remove(child)

    if (child instanceof THREE.Group) {
      const cachedEntry = [...poiMarkerGroupCache.values()].find((entry) => entry.group === child)
      if (cachedEntry) continue
    }

    disposeObject(child)
  }
}

const getMaterialColor = (material: THREE.Material) => {
  const candidate = material as THREE.Material & { color?: unknown }
  return candidate.color instanceof THREE.Color ? candidate.color : undefined
}

const getMaterialEmissive = (material: THREE.Material) => {
  const candidate = material as THREE.Material & { emissive?: unknown }
  return candidate.emissive instanceof THREE.Color ? candidate.emissive : undefined
}

const getMaterialEmissiveIntensity = (material: THREE.Material) => {
  const candidate = material as THREE.Material & { emissiveIntensity?: unknown }
  return typeof candidate.emissiveIntensity === 'number'
    ? candidate.emissiveIntensity
    : undefined
}

const setMaterialEmissiveIntensity = (material: THREE.Material, value: number) => {
  const candidate = material as THREE.Material & { emissiveIntensity?: number }
  if (typeof candidate.emissiveIntensity === 'number') {
    candidate.emissiveIntensity = value
  }
}

const clearFocusHallHighlight = () => {
  activeFocusHallMaterialStates.forEach((state) => {
    const color = getMaterialColor(state.material)
    const emissive = getMaterialEmissive(state.material)

    if (color && state.color) {
      color.copy(state.color)
    }

    if (emissive && state.emissive) {
      emissive.copy(state.emissive)
    }

    if (typeof state.emissiveIntensity === 'number') {
      setMaterialEmissiveIntensity(state.material, state.emissiveIntensity)
    }

    state.material.opacity = state.opacity
    state.material.transparent = state.transparent
    state.material.needsUpdate = true
  })

  activeFocusHallMaterialStates = []

  if (activeFocusHallGlowMesh) {
    activeFocusHallGlowMesh.parent?.remove(activeFocusHallGlowMesh)
    const material = activeFocusHallGlowMesh.material
    if (!Array.isArray(material)) {
      const glowMaterial = material as THREE.MeshBasicMaterial
      glowMaterial.map?.dispose()
    }
    disposeObject(activeFocusHallGlowMesh)
    activeFocusHallGlowMesh = null
  }
}

const applyFocusHallMaterial = (material: THREE.Material, seenMaterials: Set<THREE.Material>) => {
  if (seenMaterials.has(material)) return
  seenMaterials.add(material)

  const color = getMaterialColor(material)
  const emissive = getMaterialEmissive(material)
  const emissiveIntensity = getMaterialEmissiveIntensity(material)

  activeFocusHallMaterialStates.push({
    material,
    color: color?.clone(),
    emissive: emissive?.clone(),
    emissiveIntensity,
    opacity: material.opacity,
    transparent: material.transparent
  })

  const glowColor = new THREE.Color('#e0df00')

  if (color) {
    color.lerp(glowColor, 0.34)
  }

  if (emissive) {
    emissive.copy(glowColor)
    setMaterialEmissiveIntensity(material, Math.max(emissiveIntensity || 0, 0.76))
  }

  material.opacity = Math.max(material.opacity, 0.96)
  material.transparent = true
  material.needsUpdate = true
}

const createFocusHallGlowMesh = (poi: RenderPoi) => {
  if (!poi.positionGltf) return null

  const canvas = document.createElement('canvas')
  canvas.width = 256
  canvas.height = 256
  const context = canvas.getContext('2d')

  if (context) {
    const gradient = context.createRadialGradient(128, 128, 16, 128, 128, 118)
    gradient.addColorStop(0, 'rgba(224, 223, 0, 0.52)')
    gradient.addColorStop(0.45, 'rgba(224, 223, 0, 0.34)')
    gradient.addColorStop(0.78, 'rgba(224, 223, 0, 0.16)')
    gradient.addColorStop(1, 'rgba(224, 223, 0, 0)')

    context.clearRect(0, 0, canvas.width, canvas.height)
    context.fillStyle = gradient
    context.fillRect(0, 0, canvas.width, canvas.height)
  }

  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  const material = new THREE.MeshBasicMaterial({
    map: texture,
    transparent: true,
    opacity: 0.78,
    depthTest: false,
    depthWrite: false,
    side: THREE.DoubleSide
  })
  const hallGlowSize = Math.max(getPoiMarkerSize() * 14, getActiveModelSpan() * 0.14)
  const geometry = new THREE.PlaneGeometry(hallGlowSize, hallGlowSize * 0.72)
  const mesh = new THREE.Mesh(geometry, material)
  const [x, y, z] = poi.positionGltf

  mesh.name = 'GuideFocusHallGlow'
  mesh.position.set(x, y + 0.08, z)
  mesh.rotation.x = -Math.PI / 2
  mesh.renderOrder = 7

  return mesh
}

const showFocusHallHighlight = (poi: RenderPoi) => {
  clearFocusHallHighlight()
  if (!activeModel) return

  activeFocusHallGlowMesh = createFocusHallGlowMesh(poi)
  if (activeFocusHallGlowMesh) {
    poiGroup?.add(activeFocusHallGlowMesh)
  }

  const modelRoot = findPoiModelRoot(poi)
  if (!modelRoot) return

  const seenMaterials = new Set<THREE.Material>()
  let matchedMeshCount = 0

  modelRoot.traverse((child) => {
    if (!(child instanceof THREE.Mesh) || !child.visible) return

    matchedMeshCount += 1
    const materials = Array.isArray(child.material) ? child.material : [child.material]
    materials.forEach((material) => {
      if (material) {
        applyFocusHallMaterial(material, seenMaterials)
      }
    })
  })

  if (!matchedMeshCount) {
    clearFocusHallHighlight()
  }
}

const disposeCachedOverviewModel = () => {
  autoSwitchOverviewMaxDim = 0
  if (!cachedOverviewModel) return

  disposeObject(cachedOverviewModel)
  cachedOverviewModel = null
  cachedSharedModelUrl = ''
}

const clearRoutePreview = () => {
  activeRoutePreviewSignature = ''
  if (!routeGroup) return

  while (routeGroup.children.length) {
    const child = routeGroup.children[0]
    routeGroup.remove(child)
    disposeObject(child)
  }
}

const getRouteFloorOffsetY = (floorId: string) => {
  if (activeView.value !== 'multi' || !activeModel) return 0

  let offsetY = 0
  activeModel.traverse((child) => {
    if (offsetY) return
    if (child.userData.floorId === floorId && typeof child.userData.multiFloorOffsetY === 'number') {
      offsetY = child.userData.multiFloorOffsetY
    }
  })

  return offsetY
}

const routePointToVector = (position: [number, number, number], floorId: string, lift = 0.45) => (
  new THREE.Vector3(position[0], position[1] + getRouteFloorOffsetY(floorId) + lift, position[2])
)

const getVisibleRouteSegments = (route: GuideRouteResult): GuideRouteFloorSegment[] => {
  if (activeView.value === 'floor') {
    return route.floorSegments
      .filter((segment) => segment.floorId === currentFloor.value)
  }

  return route.floorSegments
}

const createRouteLine = (points: THREE.Vector3[], opacity: number) => {
  const geometry = new THREE.BufferGeometry().setFromPoints(points)
  const material = new THREE.LineBasicMaterial({
    color: 0x1f8f5f,
    transparent: true,
    opacity,
    depthTest: false,
    depthWrite: false
  })
  const line = new THREE.Line(geometry, material)
  line.renderOrder = 18
  return line
}

const createRouteMarkerSprite = (label: string, color: string, markerSize: number) => {
  const canvas = document.createElement('canvas')
  canvas.width = 128
  canvas.height = 128
  const context = canvas.getContext('2d')

  if (context) {
    context.clearRect(0, 0, canvas.width, canvas.height)
    context.shadowColor = 'rgba(31, 35, 41, 0.24)'
    context.shadowBlur = 16
    context.shadowOffsetY = 8
    context.beginPath()
    context.arc(64, 56, 32, 0, Math.PI * 2)
    context.fillStyle = color
    context.fill()
    context.shadowColor = 'transparent'
    context.lineWidth = 6
    context.strokeStyle = '#ffffff'
    context.stroke()

    context.beginPath()
    context.moveTo(64, 116)
    context.lineTo(43, 78)
    context.lineTo(85, 78)
    context.closePath()
    context.fillStyle = color
    context.fill()
    context.lineWidth = 6
    context.strokeStyle = '#ffffff'
    context.stroke()

    context.font = '700 30px sans-serif'
    context.textAlign = 'center'
    context.textBaseline = 'middle'
    context.fillStyle = '#ffffff'
    context.fillText(label, 64, 56, 52)
  }

  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
    map: texture,
    transparent: true,
    depthTest: false,
    depthWrite: false
  }))
  sprite.scale.set(markerSize, markerSize, markerSize)
  sprite.renderOrder = 28
  return sprite
}

const shouldShowRouteEndpoint = (endpoint: GuideRouteEndpoint) => (
  activeView.value !== 'floor' || endpoint.floorId === currentFloor.value
)

const renderRouteEndpoint = (
  endpoint: GuideRouteEndpoint,
  label: string,
  color: string,
  markerSize: number
) => {
  if (!routeGroup || !shouldShowRouteEndpoint(endpoint)) return

  const marker = createRouteMarkerSprite(label, color, markerSize)
  marker.position.copy(routePointToVector(endpoint.position, endpoint.floorId, markerSize * 0.45))
  routeGroup.add(marker)
}

const renderRouteConnectorMarkers = (route: GuideRouteResult, markerSize: number) => {
  if (!routeGroup) return

  route.connectorPoints
    .filter((point) => activeView.value !== 'floor' || point.floorId === currentFloor.value)
    .forEach((point) => {
      const marker = createRouteMarkerSprite('换', '#8a5cf6', markerSize * 0.72)
      marker.position.copy(routePointToVector(point.position, point.floorId, markerSize * 0.25))
      routeGroup!.add(marker)
    })
}

const routePositionSignature = (position: [number, number, number]) => (
  position.map((value) => Number(value.toFixed(3))).join(',')
)

const getRoutePreviewSignature = (
  route: GuideRouteResult,
  visibleSegments: GuideRouteFloorSegment[],
  markerSize: number
) => JSON.stringify({
  routeId: route.id,
  view: activeView.value,
  floor: activeView.value === 'floor' ? currentFloor.value : '',
  markerSize: Number(markerSize.toFixed(3)),
  start: {
    poiId: route.start.poiId,
    floorId: route.start.floorId,
    position: routePositionSignature(route.start.position)
  },
  end: {
    poiId: route.end.poiId,
    floorId: route.end.floorId,
    position: routePositionSignature(route.end.position)
  },
  segments: visibleSegments.map((segment) => ({
    floorId: segment.floorId,
    points: segment.points.map((point) => `${point.nodeId}:${routePositionSignature(point.position)}`)
  })),
  connectors: route.connectorPoints
    .filter((point) => activeView.value !== 'floor' || point.floorId === currentFloor.value)
    .map((point) => `${point.nodeId}:${point.floorId}:${routePositionSignature(point.position)}`)
})

const renderRoutePreview = () => {
  if (!props.showRoute || !props.routePreview || !routeGroup || !scene) {
    clearRoutePreview()
    return
  }


  const route = props.routePreview
  const targetRouteGroup = routeGroup
  const visibleSegments = getVisibleRouteSegments(route)
  const markerSize = Math.max(getPoiMarkerSize() * 0.82, 2.8)
  const signature = getRoutePreviewSignature(route, visibleSegments, markerSize)
  if (signature === activeRoutePreviewSignature) return

  clearRoutePreview()
  activeRoutePreviewSignature = signature

  visibleSegments.forEach((segment) => {
    if (segment.points.length < 2) return

    targetRouteGroup.add(createRouteLine(
      segment.points.map((point) => routePointToVector(point.position, point.floorId)),
      activeView.value === 'floor' ? 1 : 0.88
    ))
  })

  renderRouteEndpoint(route.start, '起', '#1f8f5f', markerSize)
  renderRouteEndpoint(route.end, '终', '#cf3f59', markerSize)
  renderRouteConnectorMarkers(route, markerSize)
}

const clearSceneData = () => {
  if (activeModel && scene) {
    clearFocusHallHighlight()
    scene.remove(activeModel)
    if (activeModel === cachedOverviewModel) {
      cachedOverviewModel.visible = false
    } else {
      disposeObject(activeModel, collectReusableModelResources(cachedOverviewModel))
    }
  }

  activeModel = null
  disposeFocusLabel()
  disposeFocusPulse()
  disposeFocusBase()
  selectedPOI.value = null
  clearRoutePreview()
  clearPoiGroupChildren()
}

const loadModel = (url: string, label: string, loadToken?: number) => new Promise<GLTF>((resolve, reject) => {
  if (!loader) {
    reject(new Error('GLTF 加载器未初始化'))
    return
  }

  loader.load(
    url,
    resolve,
    (event) => {
      if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return

      if (event.total > 0) {
        const modelProgress = Math.round((event.loaded / event.total) * 70)
        setProgress(20 + modelProgress, `${label}: ${Math.round((event.loaded / event.total) * 100)}%`)
      } else {
        setProgress(45, label)
      }
    },
    reject
  )
})

const prepareModel = (model: THREE.Object3D) => {
  model.traverse((child) => {
    if (child instanceof THREE.Mesh) {
      child.castShadow = false
      child.receiveShadow = true
      child.frustumCulled = false
    }
  })
}

const getObjectSize = (object: THREE.Object3D) => (
  getObjectBox(object).getSize(new THREE.Vector3())
)

const getObjectMaxDim = (object: THREE.Object3D) => {
  const size = getObjectSize(object)
  return Math.max(size.x, size.y, size.z, 1)
}

const cacheAutoSwitchOverviewMaxDim = (model: THREE.Object3D) => {
  autoSwitchOverviewMaxDim = getObjectMaxDim(model)
}

const getMultiFloorVerticalGap = (items: MultiFloorModelItem[]) => {
  const maxFloorHeight = Math.max(...items.map((item) => item.size.y), 1)
  const maxFootprint = Math.max(...items.map((item) => Math.max(item.size.x, item.size.z)), 1)

  return Math.max(maxFloorHeight * 5.2, maxFootprint * 0.32, 68)
}

const applyMultiFloorLayout = (items: MultiFloorModelItem[]) => {
  if (!items.length) return

  const verticalGap = getMultiFloorVerticalGap(items)
  const centerIndex = (items.length - 1) / 2

  items.forEach((item, index) => {
    const offsetY = (index - centerIndex) * verticalGap
    item.model.position.y += offsetY
    item.model.userData.multiFloorOffsetY = offsetY
    item.model.userData.multiFloorVerticalGap = verticalGap
  })
}

const getSharedMultiFloorModelUrl = (floorsToLoad: FloorIndexItem[]) => {
  if (!floorsToLoad.length || !floorsToLoad.every((floor) => floor.sharedModelAsset)) return ''

  const [firstFloor] = floorsToLoad
  return floorsToLoad.every((floor) => floor.modelUrl === firstFloor.modelUrl)
    ? firstFloor.modelUrl
    : ''
}

const createMultiFloorItemFromSharedModel = (sourceModel: THREE.Object3D, floor: FloorIndexItem) => {
  const floorModel = sourceModel.clone(true)
  floorModel.name = `GuideMultiFloorModel_${floor.floorId}`
  floorModel.userData.floorId = floor.floorId
  prepareModel(floorModel)
  applyModelVisibilityForView(floorModel, 'floor', floor.floorId)

  return {
    floor,
    label: formatFloorLabel(floor.floorId),
    model: floorModel,
    size: getObjectSize(floorModel)
  }
}

const disposeDetachedMultiFloorModels = (
  items: MultiFloorModelItem[],
  group: THREE.Group,
  protectedResources = collectReusableModelResources(null)
) => {
  items
    .filter((item) => !group.children.includes(item.model))
    .forEach((item) => disposeObject(item.model, protectedResources))
}

const updateLastControlDistance = () => {
  if (!controls) return

  const distance = controls.getDistance()
  if (Number.isFinite(distance)) {
    lastControlDistance = distance
  }
}

const markFloorAutoSwitchEntry = () => {
  floorAutoSwitchProtectedUntil = Date.now() + floorAutoSwitchProtectionMs
  floorExitThresholdExceededSince = 0
  floorExitZoomOutAccumulatedDistance = 0
  updateLastControlDistance()
}

const resetAutoSwitchDistanceTracking = () => {
  floorAutoSwitchProtectedUntil = 0
  floorExitThresholdExceededSince = 0
  floorExitZoomOutAccumulatedDistance = 0
  updateLastControlDistance()
}

const checkAutoSwitch = () => {
  // 检查是否启用自动切换
  if (!props.autoSwitch || autoSwitchTemporarilyDisabled || isProgrammaticCameraChange || isLoading.value) {
    return
  }

  // 检查加载锁
  if (isAutoSwitchLocked) {
    emit('autoSwitchBlocked', { reason: 'loading-locked' })
    return
  }

  // 检查冷却时间
  const now = Date.now()
  if (now - lastAutoSwitchTime < props.autoSwitchCooldown) {
    emit('autoSwitchBlocked', { reason: 'cooldown' })
    return
  }

  // 检查必要条件
  if (!controls || !activeModel || activeView.value === 'multi') return

  // 获取当前距离
  const distance = controls.getDistance()
  const previousDistance = lastControlDistance || distance
  const distanceDelta = distance - previousDistance
  lastControlDistance = distance

  // 自动切换使用全馆稳定尺寸，避免单楼层可见性过滤压低 floor -> overview 阈值。
  const maxDim = autoSwitchOverviewMaxDim || getObjectMaxDim(activeModel)

  const enterFloorAt = maxDim * props.autoSwitchThresholdLow
  const exitFloorAt = maxDim * props.autoSwitchThresholdHigh

  // 判断是否需要切换
  if (activeView.value === 'overview' && distance < enterFloorAt) {
    // 从建筑外观自动切换到单楼层
    isAutoSwitchLocked = true
    lastAutoSwitchTime = now
    floorExitThresholdExceededSince = 0
    floorExitZoomOutAccumulatedDistance = 0

    void runAutoSwitchLoad(
      {
        from: 'overview',
        to: 'floor',
        trigger: 'zoom-in',
        distance
      },
      () => loadFloor(currentFloor.value || props.initialFloorId || 'L1'),
      '单楼层模型加载失败'
    )
    return
  }

  if (activeView.value !== 'floor' || props.targetFocus) {
    floorExitThresholdExceededSince = 0
    floorExitZoomOutAccumulatedDistance = 0
    return
  }

  if (now < floorAutoSwitchProtectedUntil) {
    floorExitThresholdExceededSince = 0
    floorExitZoomOutAccumulatedDistance = 0
    return
  }

  const zoomOutIntentDelta = Math.max(maxDim * floorExitZoomOutIntentMinDeltaRatio, 1)
  const isClearZoomOutIntent = distanceDelta > zoomOutIntentDelta
  const isBeyondExitThreshold = distance > exitFloorAt

  if (!isBeyondExitThreshold) {
    floorExitThresholdExceededSince = 0
    floorExitZoomOutAccumulatedDistance = 0
    return
  }

  if (isClearZoomOutIntent) {
    floorExitZoomOutAccumulatedDistance += distanceDelta
    if (!floorExitThresholdExceededSince) {
      floorExitThresholdExceededSince = now
    }
  }

  const requiredZoomOutDistance = Math.max(maxDim * floorExitZoomOutAccumulatedRatio, 8)
  if (
    !floorExitThresholdExceededSince
    || floorExitZoomOutAccumulatedDistance < requiredZoomOutDistance
  ) {
    return
  }

  if (now - floorExitThresholdExceededSince >= floorExitThresholdHoldMs) {
    // 从单楼层自动切换到完整外围模型
    isAutoSwitchLocked = true
    lastAutoSwitchTime = now
    floorExitThresholdExceededSince = 0
    floorExitZoomOutAccumulatedDistance = 0

    void runAutoSwitchLoad(
      {
        from: 'floor',
        to: 'overview',
        trigger: 'zoom-out',
        distance
      },
      loadOverview,
      '建筑外观模型加载失败'
    )
  }
}

const runAutoSwitchLoad = async (
  event: { from: 'overview' | 'floor'; to: 'overview' | 'floor'; trigger: 'zoom-in' | 'zoom-out'; distance: number },
  loadTask: () => Promise<void>,
  fallbackMessage: string
) => {
  try {
    isLoading.value = true
    loadError.value = false
    await loadTask()
    isLoading.value = false
    emit('autoSwitch', event)
  } catch (error) {
    if (isStaleModelLoadError(error)) return

    console.error('室内 3D 自动视角切换失败:', error)
    loadError.value = true
    isLoading.value = false
    setProgress(0, error instanceof Error ? error.message : fallbackMessage)
  } finally {
    isAutoSwitchLocked = false
  }
}

const disableAutoSwitchTemporarily = (durationMs: number) => {
  autoSwitchTemporarilyDisabled = true

  if (autoSwitchDisableTimer) {
    clearTimeout(autoSwitchDisableTimer)
  }

  autoSwitchDisableTimer = setTimeout(() => {
    autoSwitchTemporarilyDisabled = false
    autoSwitchDisableTimer = null
  }, durationMs)
}

const setCameraView = (
  center: THREE.Vector3,
  maxDim: number,
  direction: THREE.Vector3,
  options: CameraFitOptions = {}
) => {
  if (!camera || !controls) return

  isProgrammaticCameraChange = true
  if (programmaticCameraTimer) {
    clearTimeout(programmaticCameraTimer)
  }

  const fov = camera.fov * (Math.PI / 180)
  const distanceFactor = options.distanceFactor ?? 0.58
  const targetOffset = options.targetOffsetRatio
    ? new THREE.Vector3(
      options.targetOffsetRatio.x * maxDim,
      options.targetOffsetRatio.y * maxDim,
      options.targetOffsetRatio.z * maxDim
    )
    : new THREE.Vector3()
  const targetCenter = center.clone().add(targetOffset)
  const distance = Math.abs(maxDim / Math.sin(fov / 2)) * distanceFactor

  try {
    camera.near = Math.max(0.1, maxDim / 1000)
    camera.far = Math.max(10000, maxDim * 20)
    camera.up.copy(options.up || new THREE.Vector3(0, 1, 0))
    camera.position.copy(targetCenter).add(direction.normalize().multiplyScalar(distance))
    camera.updateProjectionMatrix()

    controls.target.copy(targetCenter)
    if (options.screenOffsetRatio) {
      const viewDirection = controls.target.clone().sub(camera.position).normalize()
      const cameraRight = new THREE.Vector3().crossVectors(viewDirection, camera.up).normalize()
      const viewHeight = 2 * distance * Math.tan(fov / 2)
      const viewWidth = viewHeight * camera.aspect
      const screenOffset = cameraRight
        .multiplyScalar(options.screenOffsetRatio.x * viewWidth)
        .add(camera.up.clone().multiplyScalar(options.screenOffsetRatio.y * viewHeight))

      camera.position.add(screenOffset)
      controls.target.add(screenOffset)
    }
    controls.minDistance = Math.max(2, maxDim * 0.08)
    controls.maxDistance = Math.max(20, maxDim * 8)
    controls.update()
  } finally {
    programmaticCameraTimer = window.setTimeout(() => {
      isProgrammaticCameraChange = false
      programmaticCameraTimer = null
    }, 120)
  }
}

const fitCameraToObject = (object: THREE.Object3D, preset: CameraPreset = 'oblique') => {
  if (!camera || !controls) return

  const box = getObjectBox(object)
  const center = box.getCenter(new THREE.Vector3())
  const size = box.getSize(new THREE.Vector3())
  const maxDim = Math.max(size.x, size.y, size.z, 1)
  const direction = preset === 'top'
    ? new THREE.Vector3(0.02, 1, 0.02)
    : activeView.value === 'overview'
      ? new THREE.Vector3(-0.16, 1, 0.18)
      : new THREE.Vector3(0.85, 0.62, 1)
  const overviewFitOptions: CameraFitOptions = activeView.value === 'overview'
    ? {
      distanceFactor: 1.1,
      screenOffsetRatio: new THREE.Vector2(-0.085, 0.065),
      up: new THREE.Vector3(-0.82, 0, -0.57)
    }
    : {}

  setCameraView(center, maxDim, direction, overviewFitOptions)
}

const resetCamera = () => {
  if (activeModel) {
    fitCameraToObject(activeModel)
  }
}

const setCameraPreset = (preset: CameraPreset) => {
  if (activeModel) {
    fitCameraToObject(activeModel, preset)
  }
}

const zoomCamera = (direction: 'in' | 'out') => {
  if (!camera || !controls) return

  const offset = camera.position.clone().sub(controls.target)
  const currentDistance = offset.length()
  const zoomFactor = direction === 'in' ? 0.72 : 1.28
  const minDistance = controls.minDistance || 2
  const maxDistance = controls.maxDistance || 1200
  const nextDistance = Math.min(maxDistance, Math.max(minDistance, currentDistance * zoomFactor))

  if (!Number.isFinite(nextDistance) || nextDistance <= 0) return

  offset.setLength(nextDistance)
  camera.position.copy(controls.target).add(offset)
  controls.update()
  lastControlDistance = nextDistance

  if (direction === 'in' && activeView.value === 'overview' && !isLoading.value && !isAutoSwitchLocked) {
    isAutoSwitchLocked = true
    lastAutoSwitchTime = Date.now()
    floorExitThresholdExceededSince = 0
    floorExitZoomOutAccumulatedDistance = 0

    void runAutoSwitchLoad(
      {
        from: 'overview',
        to: 'floor',
        trigger: 'zoom-in',
        distance: nextDistance
      },
      () => loadFloor(currentFloor.value || props.initialFloorId || 'L1'),
      '单楼层模型加载失败'
    )
    return
  }

  handleControlChange()
}

const loadOverview = async () => {
  const packageData = renderPackage.value
  if (!packageData || !scene) return

  const loadToken = startModelLoad()
  activeView.value = 'overview'
  clearSceneData()

  if (cachedOverviewModel) {
    const targetScene = scene
    if (!targetScene) throw createStaleModelLoadError()

    activeModel = cachedOverviewModel
    activeModel.visible = true
    applyModelVisibilityForView(activeModel, 'overview')
    cacheAutoSwitchOverviewMaxDim(activeModel)
    targetScene.add(activeModel)
    fitCameraToObject(activeModel)
    resetAutoSwitchDistanceTracking()
    refreshPoiVisibilityByDistance()
    renderRoutePreview()
    return
  }

  setProgress(18, '正在加载建筑外观模型...')

  const gltf = await loadModel(packageData.overviewModelUrl, '正在加载建筑外观模型', loadToken)
  assertCurrentModelLoad(loadToken, gltf.scene)

  const targetScene = scene
  if (!targetScene) {
    disposeObject(gltf.scene)
    throw createStaleModelLoadError()
  }

  activeModel = gltf.scene
  activeModel.name = 'GuideOverviewModel'
  prepareModel(activeModel)
  applyModelVisibilityForView(activeModel, 'overview')
  cacheAutoSwitchOverviewMaxDim(activeModel)
  cachedOverviewModel = activeModel
  cachedSharedModelUrl = packageData.overviewModelUrl
  targetScene.add(activeModel)
  fitCameraToObject(activeModel)
  resetAutoSwitchDistanceTracking()
  refreshPoiVisibilityByDistance()
  renderRoutePreview()
}

const attachCachedSharedModel = (
  modelUrl: string,
  name: string,
  loadToken: number,
  floorId?: string
) => {
  if (!cachedOverviewModel || cachedSharedModelUrl !== modelUrl || !scene) return false

  const targetScene = scene
  activeModel = cachedOverviewModel
  activeModel.name = name
  activeModel.userData.floorId = floorId || ''
  activeModel.visible = true
  applyModelVisibilityForView(activeModel, floorId ? 'floor' : 'multi', floorId)
  targetScene.add(activeModel)
  assertCurrentModelLoad(loadToken)
  return true
}

const loadFloor = async (floorId: string) => {
  const floor = floorIndex.value.find((item) => item.floorId === floorId)
  if (!floor || !scene) return

  const loadToken = startModelLoad()
  activeView.value = 'floor'
  currentFloor.value = floor.floorId
  clearSceneData()
  setProgress(18, `正在加载 ${formatFloorLabel(floor.floorId)} 模型...`)

  if (!attachCachedSharedModel(floor.modelUrl, `GuideFloorModel_${floor.floorId}`, loadToken, floor.floorId)) {
    const gltf = await loadModel(floor.modelUrl, `正在加载 ${formatFloorLabel(floor.floorId)} 模型`, loadToken)
    assertCurrentModelLoad(loadToken, gltf.scene)

    const targetScene = scene
    if (!targetScene) {
      disposeObject(gltf.scene)
      throw createStaleModelLoadError()
    }

    activeModel = gltf.scene
    activeModel.name = `GuideFloorModel_${floor.floorId}`
    activeModel.userData.floorId = floor.floorId
    prepareModel(activeModel)
    if (floor.sharedModelAsset) {
      cacheAutoSwitchOverviewMaxDim(activeModel)
    }
    applyModelVisibilityForView(activeModel, 'floor', floor.floorId)
    if (floor.sharedModelAsset) {
      cachedOverviewModel = activeModel
      cachedSharedModelUrl = floor.modelUrl
    }
    targetScene.add(activeModel)
  }

  if (!activeModel) throw createStaleModelLoadError()
  fitCameraToObject(activeModel)
  markFloorAutoSwitchEntry()

  if (shouldRenderPoiMarkers.value) {
    await loadFloorPOIs(floor, loadToken)
    assertCurrentModelLoad(loadToken)
  }

  refreshPoiVisibilityByDistance()
  renderRoutePreview()
}

const loadMultiFloor = async () => {
  if (!scene || !floorIndex.value.length) return

  const loadToken = startModelLoad()
  activeView.value = 'multi'
  clearSceneData()
  setProgress(18, '正在加载多层展示模型...')

  const group = new THREE.Group()
  group.name = 'GuideMultiFloorModel'
  const orderedFloors = [...floorIndex.value].sort((a, b) => a.order - b.order)
  const loadedFloors: MultiFloorModelItem[] = []
  const sharedModelUrl = getSharedMultiFloorModelUrl(orderedFloors)

  if (sharedModelUrl) {
    let sourceModel = cachedSharedModelUrl === sharedModelUrl ? cachedOverviewModel : null

    if (!sourceModel) {
      const gltf = await loadModel(sharedModelUrl, '正在加载多层共享模型', loadToken)
      assertCurrentModelLoad(loadToken, gltf.scene)

      const targetScene = scene
      if (!targetScene) {
        disposeObject(gltf.scene)
        disposeObject(group)
        throw createStaleModelLoadError()
      }

      sourceModel = gltf.scene
      sourceModel.name = 'GuideOverviewModel'
      prepareModel(sourceModel)
      applyModelVisibilityForView(sourceModel, 'overview')
      cacheAutoSwitchOverviewMaxDim(sourceModel)
      cachedOverviewModel = sourceModel
      cachedSharedModelUrl = sharedModelUrl
    }

    const sharedSourceModel = sourceModel
    const protectedResources = collectReusableModelResources(sharedSourceModel)

    try {
      orderedFloors.forEach((floor, index) => {
        assertCurrentModelLoad(loadToken, group)
        const item = createMultiFloorItemFromSharedModel(sharedSourceModel, floor)
        loadedFloors.push(item)
        setProgress(
          18 + Math.round(((index + 1) / orderedFloors.length) * 72),
          `已准备 ${item.label}`
        )
      })

      assertCurrentModelLoad(loadToken, group)
      applyMultiFloorLayout(loadedFloors)
      loadedFloors.forEach((item) => group.add(item.model))

      const targetScene = scene
      if (!targetScene) {
        disposeObject(group, protectedResources)
        throw createStaleModelLoadError()
      }

      activeModel = group
      targetScene.add(activeModel)
      fitCameraToObject(activeModel)
      resetAutoSwitchDistanceTracking()
      refreshPoiVisibilityByDistance()
      renderRoutePreview()
      return
    } catch (error) {
      disposeObject(group, protectedResources)
      disposeDetachedMultiFloorModels(loadedFloors, group, protectedResources)

      if (isStaleModelLoadError(error)) {
        throw error
      }

      throw error
    }
  }

  for (let index = 0; index < orderedFloors.length; index += 1) {
    try {
      assertCurrentModelLoad(loadToken, group)
    } catch (error) {
      disposeDetachedMultiFloorModels(loadedFloors, group)
      throw error
    }

    const floor = orderedFloors[index]
    const label = formatFloorLabel(floor.floorId)
    const gltf = await loadModel(floor.modelUrl, `正在加载 ${label} 多层模型`, loadToken)
    if (!isCurrentModelLoad(loadToken)) {
      disposeObject(gltf.scene)
      disposeObject(group)
      disposeDetachedMultiFloorModels(loadedFloors, group)
      throw createStaleModelLoadError()
    }

    const floorModel = gltf.scene
    floorModel.name = `GuideMultiFloorModel_${floor.floorId}`
    floorModel.userData.floorId = floor.floorId
    prepareModel(floorModel)
    applyModelVisibilityForView(floorModel, 'floor', floor.floorId)
    loadedFloors.push({
      floor,
      label,
      model: floorModel,
      size: getObjectSize(floorModel)
    })
    setProgress(
      18 + Math.round(((index + 1) / orderedFloors.length) * 72),
      `已加载 ${label}`
    )
  }

  try {
    assertCurrentModelLoad(loadToken, group)
  } catch (error) {
    disposeDetachedMultiFloorModels(loadedFloors, group)
    throw error
  }

  applyMultiFloorLayout(loadedFloors)
  loadedFloors.forEach((item) => group.add(item.model))

  const targetScene = scene
  if (!targetScene) {
    disposeObject(group)
    disposeDetachedMultiFloorModels(loadedFloors, group)
    throw createStaleModelLoadError()
  }

  activeModel = group
  targetScene.add(activeModel)
  fitCameraToObject(activeModel)
  resetAutoSwitchDistanceTracking()
  refreshPoiVisibilityByDistance()
  renderRoutePreview()
}

const createPoiMaterial = (poi: RenderPoi) => {
  const canvas = document.createElement('canvas')
  canvas.width = 128
  canvas.height = 156
  const context = canvas.getContext('2d')

  if (context) {
    const color = getPoiColor(poi.primaryCategory)
    const glyph = getPoiGlyph(poi)
    context.clearRect(0, 0, canvas.width, canvas.height)

    context.shadowColor = 'rgba(31, 35, 41, 0.24)'
    context.shadowBlur = 14
    context.shadowOffsetY = 8
    context.beginPath()
    context.arc(64, 56, 31, 0, Math.PI * 2)
    context.fillStyle = color
    context.fill()
    context.shadowColor = 'transparent'
    context.lineWidth = 6
    context.strokeStyle = '#ffffff'
    context.stroke()

    context.beginPath()
    context.moveTo(64, 116)
    context.lineTo(42, 78)
    context.lineTo(86, 78)
    context.closePath()
    context.fillStyle = color
    context.fill()
    context.lineWidth = 6
    context.strokeStyle = '#ffffff'
    context.stroke()

    context.beginPath()
    context.arc(64, 56, 21, 0, Math.PI * 2)
    context.fillStyle = 'rgba(255, 255, 255, 0.94)'
    context.fill()

    context.font = '700 28px sans-serif'
    context.textAlign = 'center'
    context.textBaseline = 'middle'
    context.fillStyle = color
    context.fillText(glyph, 64, 57, 46)
  }

  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  return new THREE.SpriteMaterial({
    map: texture,
    transparent: true,
    depthTest: false,
    depthWrite: false
  })
}

const createPoiLabelSprite = (poi: RenderPoi, markerSize: number) => {
  const canvas = document.createElement('canvas')
  canvas.width = 560
  canvas.height = 168
  const context = canvas.getContext('2d')
  const label = poi.name.length > 14 ? `${poi.name.slice(0, 14)}…` : poi.name

  if (context) {
    const color = getPoiColor(poi.primaryCategory)
    context.clearRect(0, 0, canvas.width, canvas.height)
    context.shadowColor = 'rgba(31, 35, 41, 0.2)'
    context.shadowBlur = 18
    context.shadowOffsetY = 8
    context.fillStyle = 'rgba(255, 255, 255, 0.96)'
    context.beginPath()
    context.roundRect(40, 24, 480, 92, 28)
    context.fill()
    context.shadowColor = 'transparent'
    context.lineWidth = 3
    context.strokeStyle = 'rgba(31, 35, 41, 0.08)'
    context.stroke()

    context.beginPath()
    context.moveTo(258, 116)
    context.lineTo(302, 116)
    context.lineTo(280, 144)
    context.closePath()
    context.fill()

    context.beginPath()
    context.arc(86, 70, 20, 0, Math.PI * 2)
    context.fillStyle = color
    context.fill()

    context.font = '700 24px sans-serif'
    context.textAlign = 'left'
    context.textBaseline = 'middle'
    context.fillStyle = '#ffffff'
    context.fillText(getPoiGlyph(poi), 74, 70, 28)

    context.font = '700 34px sans-serif'
    context.fillStyle = '#1f2329'
    context.fillText(label, 120, 61, 360)

    context.font = '500 22px sans-serif'
    context.fillStyle = '#6b7178'
    context.fillText(`${formatFloorLabel(poi.floorId)} · ${poi.primaryCategoryZh}`, 120, 94, 360)
  }

  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
    map: texture,
    transparent: true,
    depthTest: false,
    depthWrite: false
  }))
  sprite.userData.isPoiLabel = true
  sprite.userData.labelBaseScaleX = markerSize * 5.6
  sprite.userData.labelBaseScaleY = markerSize * 1.68
  sprite.renderOrder = 30
  sprite.scale.set(sprite.userData.labelBaseScaleX, sprite.userData.labelBaseScaleY, 1)

  return sprite
}

const createPoiPulseSprite = (poi: RenderPoi, markerSize: number) => {
  const canvas = document.createElement('canvas')
  canvas.width = 160
  canvas.height = 160
  const context = canvas.getContext('2d')

  if (context) {
    const color = getPoiColor(poi.primaryCategory)
    context.clearRect(0, 0, canvas.width, canvas.height)
    context.beginPath()
    context.arc(80, 80, 56, 0, Math.PI * 2)
    context.fillStyle = `${color}30`
    context.fill()
    context.lineWidth = 6
    context.strokeStyle = `${color}66`
    context.stroke()
  }

  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
    map: texture,
    transparent: true,
    depthTest: false,
    depthWrite: false,
    opacity: 0.8
  }))
  sprite.userData.isPoiPulse = true
  sprite.userData.baseScale = markerSize * 1.9
  sprite.renderOrder = 9
  sprite.scale.set(markerSize * 1.9, markerSize * 1.9, markerSize * 1.9)

  return sprite
}

const createPoiBaseSprite = (poi: RenderPoi, markerSize: number) => {
  const canvas = document.createElement('canvas')
  canvas.width = 180
  canvas.height = 180
  const context = canvas.getContext('2d')

  if (context) {
    const color = getPoiColor(poi.primaryCategory)
    const gradient = context.createRadialGradient(90, 90, 8, 90, 90, 76)
    gradient.addColorStop(0, `${color}50`)
    gradient.addColorStop(0.45, `${color}28`)
    gradient.addColorStop(1, `${color}00`)

    context.clearRect(0, 0, canvas.width, canvas.height)
    context.beginPath()
    context.arc(90, 90, 76, 0, Math.PI * 2)
    context.fillStyle = gradient
    context.fill()
  }

  const texture = new THREE.CanvasTexture(canvas)
  texture.colorSpace = THREE.SRGBColorSpace
  const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
    map: texture,
    transparent: true,
    depthTest: false,
    depthWrite: false,
    opacity: 0.7
  }))
  sprite.userData.isPoiBase = true
  sprite.userData.baseScale = markerSize * 2.15
  sprite.renderOrder = 8
  sprite.scale.set(markerSize * 2.15, markerSize * 2.15, markerSize * 2.15)

  return sprite
}

const disposeFocusLabel = () => {
  if (!activeFocusLabelSprite) return

  activeFocusLabelSprite.parent?.remove(activeFocusLabelSprite)
  disposeObject(activeFocusLabelSprite)
  activeFocusLabelSprite = null
}

const disposeFocusPulse = () => {
  if (!activeFocusPulseSprite) return

  activeFocusPulseSprite.parent?.remove(activeFocusPulseSprite)
  disposeObject(activeFocusPulseSprite)
  activeFocusPulseSprite = null
}

const disposeFocusBase = () => {
  if (!activeFocusBaseSprite) return

  activeFocusBaseSprite.parent?.remove(activeFocusBaseSprite)
  disposeObject(activeFocusBaseSprite)
  activeFocusBaseSprite = null
}

const showFocusPoiLabel = (poi: RenderPoi) => {
  if (!poiGroup || !poi.positionGltf) return

  disposeFocusLabel()

  const markerSize = getPoiMarkerSize()
  const [x, y, z] = poi.positionGltf
  activeFocusLabelSprite = createPoiLabelSprite(poi, markerSize)
  activeFocusLabelSprite.position.set(x, y + markerSize * 2.35, z)
  updateFocusLabelScale()
  poiGroup.add(activeFocusLabelSprite)
}

const showFocusPoiBase = (poi: RenderPoi) => {
  if (!poiGroup || !poi.positionGltf) return

  disposeFocusBase()

  const markerSize = getPoiMarkerSize()
  const [x, y, z] = poi.positionGltf
  activeFocusBaseSprite = createPoiBaseSprite(poi, markerSize)
  activeFocusBaseSprite.position.set(x, y + markerSize * 0.04, z)
  poiGroup.add(activeFocusBaseSprite)
}

const showFocusPoiPulse = (poi: RenderPoi) => {
  if (!poiGroup || !poi.positionGltf) return

  disposeFocusPulse()

  const markerSize = getPoiMarkerSize()
  const [x, y, z] = poi.positionGltf
  activeFocusPulseSprite = createPoiPulseSprite(poi, markerSize)
  activeFocusPulseSprite.position.set(x, y + markerSize * 0.15, z)
  poiGroup.add(activeFocusPulseSprite)
}

const showFocusPoiAffordances = (poi: RenderPoi) => {
  showFocusHallHighlight(poi)
  showFocusPoiBase(poi)
  showFocusPoiPulse(poi)
  showFocusPoiLabel(poi)
}

const getPoiColor = (category: string) => {
  const colorMap: Record<string, string> = {
    touring_poi: '#2f6fed',
    basic_service_facility: '#1f8f5f',
    transport_circulation: '#d77b20',
    accessibility_special_service: '#8a5cf6',
    operation_experience: '#cf3f59'
  }

  return colorMap[category] || '#1f2329'
}

const getPoiMarkerSize = () => (
  activeModel
    ? Math.max(new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3()).length() * 0.012, 2.4)
    : 3
)

const getFocusLabelScaleBoost = () => {
  if (!controls || !activeModel) return 1

  const distanceRatio = controls.getDistance() / getActiveModelSpan()
  return Math.min(2.25, Math.max(1, distanceRatio / 0.36))
}

const updateFocusLabelScale = () => {
  if (!activeFocusLabelSprite) return

  const userData = getPoiSpriteUserData(activeFocusLabelSprite)
  const baseScaleX = typeof userData.labelBaseScaleX === 'number'
    ? userData.labelBaseScaleX
    : activeFocusLabelSprite.scale.x
  const baseScaleY = typeof userData.labelBaseScaleY === 'number'
    ? userData.labelBaseScaleY
    : activeFocusLabelSprite.scale.y
  const scaleBoost = getFocusLabelScaleBoost()

  activeFocusLabelSprite.scale.set(baseScaleX * scaleBoost, baseScaleY * scaleBoost, 1)
}

const setPoiSpriteFocusStyle = (sprite: THREE.Sprite, focused: boolean) => {
  const userData = getPoiSpriteUserData(sprite)
  const baseScale = typeof userData.baseScale === 'number'
    ? userData.baseScale
    : sprite.scale.x
  const scaleBoost = userData.isCorePoi ? 1.18 : 1
  const scale = focused ? baseScale * scaleBoost * 1.62 : baseScale * scaleBoost

  sprite.scale.set(scale, scale, scale)
  sprite.renderOrder = focused ? 22 : (userData.isCorePoi ? 12 : 10)
  sprite.material.opacity = focused ? 1 : 0.86
}

const updatePoiMarkerFocus = () => {
  if (!poiGroup) return

  getPoiSprites().forEach((sprite) => {
    const poi = sprite.userData.poi as RenderPoi | undefined
    setPoiSpriteFocusStyle(sprite, Boolean(poi && poi.id === activeFocusPoiId.value))
  })

  refreshPoiVisibilityByDistance()
}

const findPoiSprite = (poiId: string) => (
  getPoiSprites().find((sprite) => (sprite.userData.poi as RenderPoi | undefined)?.id === poiId)
)

const findLoadedPoi = (poiId: string) => (
  findPoiSprite(poiId)?.userData.poi as RenderPoi | undefined
)

const createPoiFromFocusRequest = (request: TargetPoiFocusRequest): RenderPoi | null => {
  if (!request.positionGltf) return null

  return {
    id: request.poiId,
    name: request.name || '目标位置',
    floorId: request.floorId,
    primaryCategory: 'target_preview',
    primaryCategoryZh: request.primaryCategoryZh || '位置预览',
    iconType: 'target_preview',
    positionGltf: request.positionGltf,
    sourceObjectName: request.sourceObjectName
  }
}

const addFocusMarkerFromRequest = (request: TargetPoiFocusRequest) => {
  if (!poiGroup || findPoiSprite(request.poiId)) return findLoadedPoi(request.poiId) || null

  const poi = createPoiFromFocusRequest(request)
  if (!poi?.positionGltf) return null

  const [x, y, z] = poi.positionGltf
  const markerSize = getPoiMarkerSize()
  const sprite = new THREE.Sprite(createPoiMaterial(poi))
  sprite.position.set(x, y + markerSize * 0.5, z)
  sprite.userData.baseScale = markerSize
  sprite.userData.poi = poi
  sprite.userData.isCorePoi = true
  setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
  poiGroup.add(sprite)

  return poi
}

const createPoiMarkerGroup = (
  floorId: string,
  displayMode: PoiDisplayMode,
  pois: RenderPoi[],
  markerSize: number
) => {
  const group = new THREE.Group()
  group.name = `GuideModelPOI_${floorId}_${displayMode}`
  group.userData.floorId = floorId
  group.userData.displayMode = displayMode

  pois.forEach((poi) => {
    const [x, y, z] = poi.positionGltf!
    const sprite = new THREE.Sprite(createPoiMaterial(poi))
    sprite.position.set(x, y + markerSize * 0.5, z)
    sprite.userData.baseScale = markerSize
    sprite.userData.poi = poi
    sprite.userData.isCorePoi = corePoiCategories.has(poi.primaryCategory)
    setPoiSpriteFocusStyle(sprite, poi.id === activeFocusPoiId.value)
    sprite.visible = shouldShowPoiAtDistance(poi, getPoiVisibilityTier())
    group.add(sprite)
  })

  return group
}

const attachPoiMarkerGroup = (entry: PoiMarkerCacheEntry) => {
  if (!poiGroup) return

  detachPoiMarkerGroups()
  if (entry.group.parent !== poiGroup) {
    poiGroup.add(entry.group)
  }
  updatePoiMarkerFocus()
}

const loadFloorPOIs = async (floor: FloorIndexItem, loadToken?: number) => {
  if (!poiGroup) return

  if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return

  const displayMode = getPoiDisplayMode()
  const markerCacheKey = getPoiMarkerCacheKey(floor.floorId, displayMode)
  const cachedEntry = poiMarkerGroupCache.get(markerCacheKey)
  if (cachedEntry) {
    attachPoiMarkerGroup(cachedEntry)
    return
  }

  const cachedPois = poiDataCache.get(floor.floorId)
  const floorPois = cachedPois || await props.modelSource.loadFloorPois(floor.floorId)
  if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) return

  poiDataCache.set(floor.floorId, floorPois)
  const validPois = floorPois.filter((poi) => shouldShowPoiInCurrentMode(poi))
  const markerSize = getPoiMarkerSize()
  const markerGroup = createPoiMarkerGroup(floor.floorId, displayMode, validPois, markerSize)
  const entry: PoiMarkerCacheEntry = {
    floorId: floor.floorId,
    displayMode,
    pois: validPois,
    group: markerGroup,
    markerSize
  }

  if (loadToken !== undefined && !isCurrentModelLoad(loadToken)) {
    disposeObject(markerGroup)
    return
  }

  poiMarkerGroupCache.set(markerCacheKey, entry)
  attachPoiMarkerGroup(entry)
}

const clearMapSelection = (shouldEmit = true) => {
  activeFocusPoiId.value = ''
  selectedPOI.value = null
  clearFocusHallHighlight()
  disposeFocusLabel()
  disposeFocusPulse()
  disposeFocusBase()
  updatePoiMarkerFocus()

  if (shouldEmit) {
    emit('selectionClear')
  }
}

const findFloorObject = (object: THREE.Object3D) => {
  let current: THREE.Object3D | null = object

  while (current) {
    if (typeof current.userData.floorId === 'string') {
      return current
    }

    current = current.parent
  }

  return null
}

const handleSceneTap = (event: PointerEvent) => {
  if (!camera || !renderer || !getContainerElement()) return

  const rect = renderer.domElement.getBoundingClientRect()
  const pointer = new THREE.Vector2(
    ((event.clientX - rect.left) / rect.width) * 2 - 1,
    -((event.clientY - rect.top) / rect.height) * 2 + 1
  )
  const raycaster = new THREE.Raycaster()
  raycaster.setFromCamera(pointer, camera)

  if (activeView.value === 'floor' && poiGroup) {
    const poiHits = raycaster.intersectObjects(getPoiSprites(), false)
    const hit = poiHits.find((item) => (
      item.object.visible
      && item.object instanceof THREE.Sprite
      && !isPoiAuxiliarySprite(item.object)
      && item.object.userData.poi
    ))

    if (hit?.object.userData.poi) {
      const poi = hit.object.userData.poi as RenderPoi
      selectedPOI.value = poi
      activeFocusPoiId.value = poi.id
      updatePoiMarkerFocus()
      showFocusPoiAffordances(poi)
      focusCameraOnPoi(poi)
      refreshPoiVisibilityByDistance()
      emit('poiClick', poi)
      return
    }
  }

  if (activeView.value === 'multi' && activeModel) {
    const floorHits = raycaster.intersectObjects(activeModel.children, true)
    const floorObject = floorHits
      .map((hit) => findFloorObject(hit.object))
      .find((object): object is THREE.Object3D => Boolean(object))

    if (floorObject?.userData.floorId) {
      disableAutoSwitchTemporarily(10000)
      void handleFloorChange(floorObject.userData.floorId as string)
      return
    }
  }

  clearMapSelection()
}

const handlePointerDown = (event: PointerEvent) => {
  pointerDownState = {
    x: event.clientX,
    y: event.clientY
  }
}

const handlePointerUp = (event: PointerEvent) => {
  if (!pointerDownState) return

  const moveDistance = Math.hypot(event.clientX - pointerDownState.x, event.clientY - pointerDownState.y)
  pointerDownState = null

  if (moveDistance > 8) return

  handleSceneTap(event)
}

const emitTargetFocus = (
  request: TargetPoiFocusRequest,
  status: TargetPoiFocusResult['status'],
  message?: string
) => {
  emit('targetFocus', {
    requestId: request.requestId,
    poiId: request.poiId,
    floorId: request.floorId,
    status,
    message
  })
}

const clearTargetFocus = () => {
  pendingTargetFocus = null
  clearMapSelection(false)
}

const isSceneReadyForTargetFocus = () => (
  Boolean(scene && camera && controls && loader && floorIndex.value.length)
  && !isLoading.value
  && !loadError.value
)

const focusCameraOnPoi = (poi: RenderPoi) => {
  if (!camera || !controls || !poi.positionGltf) return

  const [x, y, z] = poi.positionGltf
  const target = new THREE.Vector3(x, y, z)
  const modelSize = activeModel
    ? new THREE.Box3().setFromObject(activeModel).getSize(new THREE.Vector3())
    : new THREE.Vector3(80, 80, 80)
  const maxDim = Math.max(modelSize.x, modelSize.y, modelSize.z, 1)
  const focusDistanceFactor = Math.max(props.targetFocusDistanceFactor, 0.1)
  const focusMaxDistanceFactor = Math.max(0.55, focusDistanceFactor * 1.4)
  const distance = Math.min(
    Math.max(maxDim * focusDistanceFactor, 28),
    Math.max(maxDim * focusMaxDistanceFactor, 80)
  )
  const direction = new THREE.Vector3(0.72, 0.58, 1).normalize()

  camera.position.copy(target).add(direction.multiplyScalar(distance))
  camera.near = Math.max(0.1, maxDim / 1200)
  camera.far = Math.max(10000, maxDim * 20)
  camera.updateProjectionMatrix()

  controls.target.copy(target)
  controls.minDistance = Math.max(2, maxDim * 0.025)
  controls.maxDistance = Math.max(20, maxDim * 6)
  controls.update()
}

const focusTargetPoi = async (request: TargetPoiFocusRequest) => {
  if (!request.poiId || !request.floorId) {
    emitTargetFocus(request, 'missing', '目标位置数据不完整')
    return false
  }

  const floor = floorIndex.value.find((item) => item.floorId === request.floorId)
  if (!floor) {
    emitTargetFocus(request, 'missing', '目标楼层不在当前三维资源中')
    return false
  }

  activeFocusPoiId.value = request.poiId

  try {
    if (activeView.value !== 'floor' || currentFloor.value !== request.floorId) {
      isLoading.value = true
      loadError.value = false
      await loadFloor(request.floorId)
      isLoading.value = false
      emit('floorChange', request.floorId)
    } else if (shouldRenderPoiMarkers.value && !getPoiSprites().length) {
      await loadFloorPOIs(floor, modelLoadVersion)
    }

    const poi = findLoadedPoi(request.poiId) || addFocusMarkerFromRequest(request) || createPoiFromFocusRequest(request)

    if (!poi?.positionGltf) {
      selectedPOI.value = null
      disposeFocusLabel()
      updatePoiMarkerFocus()
      emitTargetFocus(request, 'missing', '目标暂无三维坐标')
      return false
    }

    selectedPOI.value = poi
    updatePoiMarkerFocus()
    showFocusPoiAffordances(poi)
    focusCameraOnPoi(poi)
    refreshPoiVisibilityByDistance()
    emitTargetFocus(request, 'focused')
    return true
  } catch (error) {
    if (isStaleModelLoadError(error)) return false

    console.error('目标 POI 聚焦失败:', error)
    loadError.value = true
    isLoading.value = false
    setProgress(0, error instanceof Error ? error.message : '目标位置聚焦失败')
    emitTargetFocus(request, 'error', error instanceof Error ? error.message : '目标位置聚焦失败')
    return false
  }
}

const queueTargetFocus = (request: TargetPoiFocusRequest | null) => {
  if (!request) {
    clearTargetFocus()
    return
  }

  pendingTargetFocus = request

  if (!isSceneReadyForTargetFocus()) return

  const nextRequest = pendingTargetFocus
  if (!nextRequest) return

  pendingTargetFocus = null
  targetFocusQueue = targetFocusQueue
    .then(() => focusTargetPoi(nextRequest))
    .finally(() => {
      if (pendingTargetFocus) {
        queueTargetFocus(pendingTargetFocus)
      }
    })
}

const loadModelPackage = async () => {
  setProgress(8, '正在读取室内导览资源...')
  const packageData = await props.modelSource.loadPackage()
  renderPackage.value = packageData

  setProgress(14, '正在读取楼层索引...')
  floorIndex.value = [...packageData.floors].sort((a, b) => a.order - b.order)

  if (!floorIndex.value.some((floor) => floor.floorId === currentFloor.value)) {
    currentFloor.value = floorIndex.value.find((floor) => floor.floorId === 'L1')?.floorId || floorIndex.value[0]?.floorId || 'L1'
  }

  if (props.initialView === 'floor') {
    await loadFloor(currentFloor.value)
    return
  }

  if (props.initialView === 'multi') {
    await loadMultiFloor()
    return
  }

  await loadOverview()
}

const init3DScene = async () => {
  try {
    disposeScene()
    isDisposed = false
    isLoading.value = true
    loadError.value = false
    selectedPOI.value = null
    setProgress(0, '正在初始化三维场景...')

    await initThree()
    await loadModelPackage()

    setProgress(100, '室内三维模型加载完成')
    isLoading.value = false
    queueTargetFocus(pendingTargetFocus || props.targetFocus || null)
  } catch (error) {
    if (isStaleModelLoadError(error)) return

    console.error('室内 3D 模型资源加载失败:', error)
    loadError.value = true
    isLoading.value = false
    setProgress(0, error instanceof Error ? error.message : '请检查模型资源或网络状态后重试')
  }
}

const handleFloorChange = async (floorId: string) => {
  try {
    isLoading.value = true
    loadError.value = false
    await loadFloor(floorId)
    updatePoiVisibilityByDistance()
    isLoading.value = false
    emit('floorChange', floorId)
  } catch (error) {
    if (isStaleModelLoadError(error)) return

    console.error('楼层模型加载失败:', error)
    loadError.value = true
    isLoading.value = false
    setProgress(0, error instanceof Error ? error.message : '楼层模型加载失败')
  }
}

const showOverview = async () => {
  // 建筑外观不再作为手动切换入口；只在初始加载与缩放自动切换时展示。
  if (activeView.value === 'overview') {
    resetCamera()
  }
}

const showMultiFloor = async () => {
  try {
    isLoading.value = true
    loadError.value = false
    activeFocusPoiId.value = ''
    await loadMultiFloor()
    isLoading.value = false
  } catch (error) {
    if (isStaleModelLoadError(error)) return

    console.error('多层展示模型加载失败:', error)
    loadError.value = true
    isLoading.value = false
    setProgress(0, error instanceof Error ? error.message : '多层展示模型加载失败')
  }
}

const retryLoad = () => {
  init3DScene()
}

const disposeScene = () => {
  isDisposed = true
  invalidateModelLoads()

  if (animationId) {
    window.cancelAnimationFrame(animationId)
    animationId = 0
  }

  const container = getContainerElement()
  container?.removeEventListener('pointerdown', handlePointerDown)
  container?.removeEventListener('pointerup', handlePointerUp)

  resizeObserver?.disconnect()
  resizeObserver = null

  // 清理自动切换相关资源
  if (controls) {
    controls.removeEventListener('change', handleControlChange)
    controls.dispose()
  }
  controls = null

  if (autoSwitchDisableTimer) {
    clearTimeout(autoSwitchDisableTimer)
    autoSwitchDisableTimer = null
  }

  if (programmaticCameraTimer) {
    clearTimeout(programmaticCameraTimer)
    programmaticCameraTimer = null
  }
  isProgrammaticCameraChange = false
  lastControlDistance = 0
  floorAutoSwitchProtectedUntil = 0
  floorExitThresholdExceededSince = 0
  floorExitZoomOutAccumulatedDistance = 0

  clearSceneData()
  disposePoiMarkerCache()
  disposeCachedOverviewModel()
  scene?.clear()
  scene = null
  camera = null
  loader = null
  dracoLoader?.dispose()
  dracoLoader = null
  poiGroup = null
  routeGroup = null

  if (renderer) {
    renderer.dispose()
    renderer.forceContextLoss()
    renderer.domElement.remove()
  }

  renderer = null
}

defineExpose({
  switchFloor: handleFloorChange,
  showOverview,
  showMultiFloor,
  resetCamera,
  setCameraPreset,
  zoomCamera,
  focusTargetPoi: (request: TargetPoiFocusRequest) => {
    queueTargetFocus(request)
  },
  clearSelection: clearMapSelection,
  clearRoute: clearRoutePreview,
  clearNavigation: () => clearMapSelection(false),
  disableAutoSwitchTemporarily
})

watch(() => props.modelSource, () => {
  init3DScene()
})

watch(() => [props.showControls, props.touchGestureMode], () => {
  syncControlInteractionOptions()
})

watch(() => props.targetFocus, (request) => {
  queueTargetFocus(request || null)
}, {
  deep: true,
  immediate: true
})

watch(() => [props.routePreview, props.showRoute], () => {
  renderRoutePreview()
}, {
  deep: true
})

watch(() => props.initialView, (newView) => {
  if (newView === 'floor' || newView === 'multi') {
    activeView.value = newView
  }
})

onMounted(() => {
  init3DScene()
})

onUnmounted(() => {
  disposeScene()
})
</script>

<style scoped lang="scss">
.three-map-container {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  background: #f1f3ee;
}

.three-canvas-wrapper {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;

  :deep(canvas) {
    width: 100% !important;
    height: 100% !important;
    display: block;
    touch-action: none;
  }
}

.loading-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(241, 243, 238, 0.9);
  z-index: 20;
}

.loading-content {
  width: min(260px, calc(100vw - 80px));
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}

.loading-spinner {
  width: 34px;
  height: 34px;
  border: 3px solid rgba(31, 35, 41, 0.16);
  border-top-color: #1f2329;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

.loading-text {
  font-size: 13px;
  line-height: 18px;
  color: #1f2329;
  text-align: center;
}

.loading-progress {
  width: 100%;
  height: 4px;
  overflow: hidden;
  background: rgba(31, 35, 41, 0.12);
  border-radius: 2px;
}

.loading-bar {
  height: 100%;
  background: #1f8f5f;
  transition: width 0.2s ease;
}

.error-overlay {
  background: rgba(31, 35, 41, 0.28);
}

.error-content {
  padding: 18px;
  box-sizing: border-box;
  background: rgba(255, 255, 255, 0.94);
  border: 1px solid rgba(31, 35, 41, 0.1);
  border-radius: 8px;
}

.error-title {
  font-size: 16px;
  line-height: 22px;
  font-weight: 700;
  color: #1f2329;
}

.error-text {
  font-size: 13px;
  line-height: 18px;
  color: #5f666d;
  text-align: center;
}

.retry-btn {
  min-width: 96px;
  height: 34px;
  padding: 0 16px;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  background: #1f2329;
  border-radius: 6px;
}

.retry-text {
  font-size: 13px;
  line-height: 18px;
  font-weight: 600;
  color: #ffffff;
}

.map-toolbar {
  position: absolute;
  left: 12px;
  top: 50%;
  transform: translateY(-50%);
  z-index: 12;
}

.overview-btn {
  height: 34px;
  padding: 0 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  background: rgba(255, 255, 255, 0.86);
  border: 1px solid rgba(31, 35, 41, 0.12);
  border-radius: 6px;
  box-shadow: 0 4px 14px rgba(31, 35, 41, 0.12);
}

.overview-btn.active {
  background: #1f2329;
}

.overview-text {
  font-size: 12px;
  line-height: 16px;
  font-weight: 700;
  color: #1f2329;
}

.overview-btn.active .overview-text {
  color: #ffffff;
}

.poi-detail-popup {
  position: absolute;
  left: 50%;
  bottom: 118px;
  width: min(280px, calc(100vw - 64px));
  padding: 12px 14px;
  transform: translateX(-50%);
  box-sizing: border-box;
  background: rgba(255, 255, 255, 0.94);
  border: 1px solid rgba(31, 35, 41, 0.1);
  border-radius: 8px;
  box-shadow: 0 10px 28px rgba(31, 35, 41, 0.16);
  z-index: 14;
}

.poi-content {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.poi-name {
  font-size: 15px;
  line-height: 20px;
  font-weight: 700;
  color: #1f2329;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.poi-floor {
  font-size: 12px;
  line-height: 16px;
  color: #5f666d;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
</style>
