import * as Cesium from 'cesium'
import h337 from 'heatmap.js'
import type {
  AddGeoJsonLayerParams, AddGeoJsonPrimitiveParams, AddHeatmapParams, LayerInfo, SetBasemapParams,
  LayerStyle, ImageryLayerStyle, PrimitiveLayerStyle, Load3dTilesParams, AddGaussianSplatParams, LoadTerrainParams, LoadImageryServiceParams,
  LoadCzmlParams, LoadKmlParams, UpdateLayerStyleParams,
  GetLayerSchemaParams, LayerSchemaResult, LayerSchemaField,
} from '../types'
import { parseColor } from '../utils'
import { BASEMAP_PRESETS } from './basemap-presets'

// ==================== 图层状态（由 Bridge 实例持有） ====================

interface CesiumRefs {
  dataSource?: Cesium.GeoJsonDataSource | Cesium.CzmlDataSource | Cesium.KmlDataSource
  entity?: Cesium.Entity
  labelEntities?: Cesium.Entity[]
  tileset?: Cesium.Cesium3DTileset
  primitive?: any
  imageryLayer?: Cesium.ImageryLayer
  styleEntities?: Cesium.Entity[]
  polygonOutlines?: Map<Cesium.Entity, Cesium.Entity[]>
  movingEntity?: Cesium.Entity
  trailEntity?: Cesium.Entity
  trajectoryId?: string
}

export class LayerManager {
  private _layers: LayerInfo[] = []
  private _cesiumRefs = new Map<string, CesiumRefs>()
  private _viewer: Cesium.Viewer

  constructor(viewer: Cesium.Viewer) {
    this._viewer = viewer
  }

  get layers(): LayerInfo[] {
    return this._layers
  }

  getCesiumRefs(layerId: string): CesiumRefs | undefined {
    return this._cesiumRefs.get(layerId)
  }

  setCesiumRefs(layerId: string, refs: Partial<CesiumRefs>): void {
    this._cesiumRefs.set(layerId, refs as CesiumRefs)
  }

  // ==================== addGeoJsonLayer ====================

  async addGeoJsonLayer(params: AddGeoJsonLayerParams): Promise<LayerInfo> {
    const { id, name, data, url, style, dataRefId } = params

    if (!data && !url) throw new Error('Either "data" or "url" must be provided')

    const layerId = id ?? `layer_${Date.now()}`
    const layerName = name ?? layerId
    const color = style?.color ?? DEFAULT_LAYER_COLOR
    const opacity = style?.opacity ?? 0.6
    const pointSize = style?.pointSize ?? 10

    this.removeLayer(layerId)

    const cesiumColor = parseColor(color).withAlpha(opacity)

    const ds = await Cesium.GeoJsonDataSource.load(url ?? data, {
      stroke: cesiumColor,
      fill: cesiumColor.withAlpha(opacity * 0.4),
      strokeWidth: 3,
      markerSize: 1,
      markerColor: cesiumColor,
      clampToGround: true,
    })
    ds.name = layerName

    // 将默认 pin 图标替换为 canvas 圆点图片（保留 billboard 以兼容 EntityCluster）
    const circleImage = createCircleImage(pointSize * 2, '#FFFFFF', 1)
    const entities = ds.entities.values
    // 在下方追加描边 polyline 前快照原始要素，专题着色只作用于原始要素而非描边
    const styleEntities = [...entities]
    const polygonOutlines = new Map<Cesium.Entity, Cesium.Entity[]>()
    const labelField = params.labelField
    const ls = params.labelStyle
    const labelFont = ls?.font ?? '12px sans-serif'
    const labelFillColor = ls?.fillColor
      ? parseColor(ls.fillColor)
      : Cesium.Color.WHITE
    const labelOutlineColor = ls?.outlineColor
      ? parseColor(ls.outlineColor)
      : Cesium.Color.BLACK
    const labelOutlineWidth = ls?.outlineWidth ?? 2
    const labelOffset = ls?.pixelOffset
      ? new Cesium.Cartesian2(ls.pixelOffset[0], ls.pixelOffset[1])
      : new Cesium.Cartesian2(0, -pointSize - 4)

    for (let i = 0; i < entities.length; i++) {
      const e = entities[i]!
      if (e.billboard) {
        e.billboard.image = new Cesium.ConstantProperty(circleImage) as any
        e.billboard.color = new Cesium.ConstantProperty(cesiumColor) as any
        e.billboard.width = new Cesium.ConstantProperty(pointSize * 2) as any
        e.billboard.height = new Cesium.ConstantProperty(pointSize * 2) as any
        e.billboard.heightReference = new Cesium.ConstantProperty(Cesium.HeightReference.CLAMP_TO_GROUND) as any
        e.billboard.disableDepthTestDistance = new Cesium.ConstantProperty(Number.POSITIVE_INFINITY) as any
      }

      // 同时附加文字标注（圆点+文字同图层）
      if (labelField && e.properties && e.position) {
        const val = e.properties[labelField]?.getValue(Cesium.JulianDate.now())
        if (val != null && val !== '') {
          e.label = new Cesium.LabelGraphics({
            text: String(val),
            font: labelFont,
            fillColor: labelFillColor,
            outlineColor: labelOutlineColor,
            outlineWidth: labelOutlineWidth,
            style: Cesium.LabelStyle.FILL_AND_OUTLINE,
            pixelOffset: labelOffset,
            scale: ls?.scale ?? 1.0,
            verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
            heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
            disableDepthTestDistance: Number.POSITIVE_INFINITY,
          })
        }
      }
    }

    // 为贴地 polygon 添加 polyline 描边（clampToGround 下 polygon outline 不支持）
    const strokeWidth = style?.strokeWidth ?? 3
    for (let i = 0; i < entities.length; i++) {
      const e = entities[i]!
      if (e.polygon) {
        const hierarchy = e.polygon.hierarchy?.getValue(Cesium.JulianDate.now())
        if (hierarchy?.positions) {
          const outlines: Cesium.Entity[] = []
          const positions = [...hierarchy.positions, hierarchy.positions[0]]
          const outline = ds.entities.add({
            polyline: {
              positions,
              width: strokeWidth,
              material: cesiumColor,
              clampToGround: true,
            },
          })
          outlines.push(outline)
          if (hierarchy.holes) {
            for (const hole of hierarchy.holes) {
              if (hole.positions) {
                const holeOutline = ds.entities.add({
                  polyline: {
                    positions: [...hole.positions, hole.positions[0]],
                    width: strokeWidth,
                    material: cesiumColor,
                    clampToGround: true,
                  },
                })
                outlines.push(holeOutline)
              }
            }
          }
          if (outlines.length) polygonOutlines.set(e, outlines)
        }
      }
    }

    // choropleth 分级着色
    const choropleth = style?.choropleth
    if (choropleth?.field && choropleth?.breaks && choropleth?.colors) {
      applyChoroplethStyle(styleEntities, choropleth.field, choropleth.breaks, choropleth.colors, opacity, polygonOutlines)
    }

    const category = style?.category
    if (category?.field) {
      applyCategoryStyle(styleEntities, category.field, category.colors, opacity, polygonOutlines)
    }

    if (style?.randomColor) {
      applyRandomColorStyle(styleEntities, opacity, polygonOutlines)
    }

    if (style?.gradient) {
      applyGradientStyle(styleEntities, style.gradient, opacity, polygonOutlines)
    }

    this._viewer.dataSources.add(ds)

    const geomType = data ? detectGeometryType(data) : detectGeometryTypeFromDataSource(ds)
    const info: LayerInfo = {
      id: layerId,
      name: layerName,
      type: geomType,
      visible: true,
      color,
      dataRefId,
    }
    this._cesiumRefs.set(layerId, { dataSource: ds, styleEntities, polygonOutlines })
    this._layers.push(info)

    this._viewer.flyTo(ds, { duration: 1.5 })
    return info
  }

  // ==================== addGeoJsonPrimitive ====================

  async addGeoJsonPrimitive(params: AddGeoJsonPrimitiveParams): Promise<LayerInfo> {
    const { id, name, data, url, allowPicking, show } = params

    if (!data && !url) throw new Error('Either "data" or "url" must be provided')

    const layerId = id ?? `geojson_prim_${Date.now()}`
    const layerName = name ?? layerId

    this.removeLayer(layerId)

    const opts: Record<string, unknown> = {}
    if (allowPicking !== undefined) opts.allowPicking = allowPicking
    if (show !== undefined) opts.show = show

    const GeoJsonPrimitive = (Cesium as any).GeoJsonPrimitive
    if (!GeoJsonPrimitive) {
      throw new Error('GeoJsonPrimitive is not available in this CesiumJS version')
    }

    let primitive: any
    if (url) {
      primitive = await GeoJsonPrimitive.fromUrl(url, opts)
    } else {
      primitive = GeoJsonPrimitive.fromGeoJson(data, opts)
    }

    this._viewer.scene.primitives.add(primitive)

    const featureCount = primitive.featureCount ?? 0
    const info: LayerInfo = {
      id: layerId,
      name: layerName,
      type: 'geojson-primitive',
      visible: show !== false,
      color: '#10B981',
    }
    this._cesiumRefs.set(layerId, { primitive })
    this._layers.push(info)

    this._viewer.flyTo(primitive, { duration: 1.5 }).catch(() => {
      // flyTo may fail if primitive has no bounding volume — ignore
    })

    return { ...info, featureCount } as LayerInfo & { featureCount: number }
  }

  // ==================== addHeatmap ====================

  async addHeatmap(params: AddHeatmapParams): Promise<LayerInfo> {
    const {
      id, name, data,
      radius = 30, gradient,
      blur = 0.85,
      maxOpacity = 0.8, minOpacity = 0,
      resolution = 512,
    } = params
    const layerId = id ?? `heatmap_${Date.now()}`
    const layerName = name ?? layerId

    // 从 GeoJSON 提取点坐标 + 权重
    const features = (data as any)?.features ?? []
    const points: Array<{ lon: number; lat: number; weight: number }> = []
    for (const f of features) {
      const geom = f?.geometry
      if (geom?.type === 'Point') {
        const [lon, lat] = geom.coordinates as [number, number]
        const w = f.properties?.weight ?? f.properties?.value ?? f.properties?.density ?? 1
        points.push({ lon, lat, weight: typeof w === 'number' ? w : 1 })
      }
    }

    if (!points.length) {
      return this.addGeoJsonLayer({ id: layerId, name: layerName, data, style: { color: '#FF4500', opacity: 0.8 } })
    }

    // 计算数据范围
    const lons = points.map(p => p.lon)
    const lats = points.map(p => p.lat)
    const lonRange = Math.max(...lons) - Math.min(...lons)
    const latRange = Math.max(...lats) - Math.min(...lats)
    const padLon = lonRange * 0.05 || 0.01
    const padLat = latRange * 0.05 || 0.01
    const west = Math.min(...lons) - padLon
    const south = Math.min(...lats) - padLat
    const east = Math.max(...lons) + padLon
    const north = Math.max(...lats) + padLat

    // 使用 heatmap.js 生成 canvas
    const canvas = generateHeatmapCanvas(points, radius, { west, south, east, north }, {
      gradient, blur, maxOpacity, minOpacity, resolution,
    })
    if (!canvas) {
      return this.addGeoJsonLayer({ id: layerId, name: layerName, data, style: { color: '#FF4500', opacity: 0.8 } })
    }

    const entity = this._viewer.entities.add({
      rectangle: {
        coordinates: Cesium.Rectangle.fromDegrees(west, south, east, north),
        material: new Cesium.ImageMaterialProperty({
          image: new Cesium.ConstantProperty(canvas),
          transparent: true,
        }),
        classificationType: Cesium.ClassificationType.BOTH,
      },
    })

    const info: LayerInfo = {
      id: layerId,
      name: layerName,
      type: '热力图',
      visible: true,
      color: '#FF4500',
    }
    this._cesiumRefs.set(layerId, { entity })
    this._layers.push(info)

    this._viewer.camera.flyTo({
      destination: Cesium.Rectangle.fromDegrees(west, south, east, north),
      duration: 1.5,
    })

    return info
  }

  // ==================== 基础图层操作 ====================

  removeLayer(id: string): void {
    const idx = this._layers.findIndex(l => l.id === id)
    if (idx === -1) return
    const refs = this._cesiumRefs.get(id)
    if (refs) {
      if (refs.dataSource) this._viewer.dataSources.remove(refs.dataSource, true)
      if (refs.entity) this._viewer.entities.remove(refs.entity)
      if (refs.labelEntities) {
        for (const e of refs.labelEntities) this._viewer.entities.remove(e)
      }
      if (refs.tileset) this._viewer.scene.primitives.remove(refs.tileset)
      if (refs.primitive) this._viewer.scene.primitives.remove(refs.primitive)
      if (refs.imageryLayer) this._viewer.imageryLayers.remove(refs.imageryLayer)
      if (refs.movingEntity) this._viewer.entities.remove(refs.movingEntity)
      if (refs.trailEntity) this._viewer.entities.remove(refs.trailEntity)
      this._cesiumRefs.delete(id)
    }
    this._layers.splice(idx, 1)
  }

  /** 根据 Cesium Entity 引用反查并移除对应图层记录（不再删除 entity 本身） */
  untrackByEntity(entity: Cesium.Entity): string | undefined {
    for (const [layerId, refs] of this._cesiumRefs) {
      if (refs.entity === entity) {
        const idx = this._layers.findIndex(l => l.id === layerId)
        if (idx !== -1) this._layers.splice(idx, 1)
        this._cesiumRefs.delete(layerId)
        return layerId
      }
    }
    return undefined
  }

  setLayerVisibility(id: string, visible: boolean): void {
    const layer = this._layers.find(l => l.id === id)
    if (!layer) return
    layer.visible = visible
    const refs = this._cesiumRefs.get(id)
    if (!refs) return
    if (refs.dataSource) refs.dataSource.show = visible
    if (refs.entity) refs.entity.show = visible
    if (refs.labelEntities) {
      for (const e of refs.labelEntities) e.show = visible
    }
    if (refs.tileset) refs.tileset.show = visible
    if (refs.primitive) refs.primitive.show = visible
    if (refs.imageryLayer) refs.imageryLayer.show = visible
    if (refs.movingEntity) refs.movingEntity.show = visible
    if (refs.trailEntity) refs.trailEntity.show = visible
  }

  toggleLayer(id: string): void {
    const layer = this._layers.find(l => l.id === id)
    if (layer) {
      this.setLayerVisibility(id, !layer.visible)
    }
  }

  zoomToLayer(id: string): void {
    const refs = this._cesiumRefs.get(id)
    if (!refs) return
    if (refs.dataSource) { this._viewer.flyTo(refs.dataSource); return }
    if (refs.labelEntities?.length) { this._viewer.flyTo(refs.labelEntities); return }
    if (refs.entity) { this._viewer.flyTo(refs.entity); return }
    if (refs.tileset) { this._viewer.flyTo(refs.tileset); return }
    if (refs.primitive) { this._viewer.flyTo(refs.primitive); return }
    if (refs.movingEntity) { this._viewer.flyTo(refs.movingEntity); return }
  }

  updateLayerStyle(params: UpdateLayerStyleParams): boolean {
    const layer = this._layers.find(l => l.id === params.layerId)
    if (!layer) return false

    const refs = this._cesiumRefs.get(params.layerId)

    // 标注图层样式更新
    const ls = params.labelStyle
    if (ls && refs?.labelEntities) {
      const entities: Cesium.Entity[] = refs.labelEntities
      for (const entity of entities) {
        if (!entity.label) continue
        if (ls.font || ls.fontSize) {
          const fontSize = ls.fontSize ?? 14
          entity.label.font = new Cesium.ConstantProperty(ls.font ?? `${fontSize}px sans-serif`)
        }
        if (ls.fillColor) {
          entity.label.fillColor = new Cesium.ConstantProperty(
            parseColor(ls.fillColor)
          )
        }
        if (ls.outlineColor) {
          entity.label.outlineColor = new Cesium.ConstantProperty(
            parseColor(ls.outlineColor)
          )
        }
        if (ls.outlineWidth !== undefined) {
          entity.label.outlineWidth = new Cesium.ConstantProperty(ls.outlineWidth)
        }
        if (ls.scale !== undefined) {
          entity.label.scale = new Cesium.ConstantProperty(ls.scale)
        }
        if (ls.showBackground !== undefined) {
          entity.label.showBackground = new Cesium.ConstantProperty(ls.showBackground)
        }
        if (ls.backgroundColor) {
          entity.label.backgroundColor = new Cesium.ConstantProperty(
            parseColor(ls.backgroundColor)
          )
        }
        if (ls.pixelOffset) {
          entity.label.pixelOffset = new Cesium.ConstantProperty(
            new Cesium.Cartesian2(ls.pixelOffset[0], ls.pixelOffset[1])
          )
        }
      }
      if (ls.fillColor) layer.color = ls.fillColor
      return true
    }

    // GeoJSON 图层样式更新
    const gs = params.layerStyle
    if (gs && refs?.dataSource) {
      const ds = refs.dataSource
      if (!isValidLayerStyleForUpdate(gs)) return false
      if (hasThematicStyle(gs) && !refs.styleEntities) return false

      applyBasicLayerStyle(ds.entities.values, gs, layer.color, refs.polygonOutlines)
      applyThematicLayerStyle(gs, refs.styleEntities ?? ds.entities.values, refs.polygonOutlines)
      if (gs.color) layer.color = gs.color
      return true
    }

    const imageryStyle = params.imageryStyle
    if (imageryStyle && refs?.imageryLayer) {
      if (!isValidImageryLayerStyle(imageryStyle)) return false
      applyImageryLayerStyle(refs.imageryLayer, imageryStyle)
      this._viewer.scene.requestRender?.()
      return true
    }

    const primitiveStyle = params.primitiveStyle
    if (primitiveStyle && refs?.primitive) {
      if (!isValidPrimitiveLayerStyle(primitiveStyle)) return false
      applyGeoJsonPrimitiveStyle(refs.primitive, primitiveStyle)
      this._viewer.scene.requestRender?.()
      if (primitiveStyle.color) layer.color = primitiveStyle.color
      return true
    }

    // 3D Tiles 样式更新
    const ts = params.tileStyle
    if (ts && refs?.tileset) {
      const styleObj: Record<string, unknown> = {}
      if (ts.color) styleObj.color = ts.color
      if (ts.show) styleObj.show = ts.show
      if (ts.pointSize) styleObj.pointSize = ts.pointSize
      if (ts.meta) Object.assign(styleObj, { meta: ts.meta })
      refs.tileset.style = new Cesium.Cesium3DTileStyle(styleObj)
      if (ts.color) layer.color = ts.color
      return true
    }

    return false
  }

  listLayers(): LayerInfo[] {
    return this._layers.map(({ id, name, type, visible, color, dataRefId }) => ({ id, name, type, visible, color, dataRefId }))
  }

  getLayerSchema(params: GetLayerSchemaParams): LayerSchemaResult {
    const layer = this._layers.find(l => l.id === params.layerId)
    if (!layer) throw new Error(`Layer not found: ${params.layerId}`)

    const refs = this._cesiumRefs.get(params.layerId)

    // 3D Tiles: 从已加载瓦片的 feature batch table 中提取字段
    if (refs?.tileset) {
      return this._getTilesetSchema(params.layerId, layer.name, refs.tileset)
    }

    const ds = refs?.dataSource
    if (!ds) throw new Error(`Layer '${params.layerId}' has no DataSource or Tileset`)

    const entities = ds.entities.values
    // 从首个有属性的实体中采集字段
    const fieldMap = new Map<string, LayerSchemaField>()
    for (const e of entities) {
      if (!e.properties) continue
      for (const name of e.properties.propertyNames) {
        if (fieldMap.has(name)) continue
        const val = e.properties[name]?.getValue?.(Cesium.JulianDate.now())
        fieldMap.set(name, {
          name,
          type: val === null || val === undefined ? 'unknown' : Array.isArray(val) ? 'array' : typeof val,
          sample: val,
        })
      }
      // 一旦采集到字段就可以停止（所有实体字段结构一致）
      if (fieldMap.size > 0) break
    }

    return {
      layerId: params.layerId,
      layerName: layer.name,
      entityCount: entities.length,
      fields: Array.from(fieldMap.values()),
    }
  }

  private _getTilesetSchema(layerId: string, layerName: string, tileset: Cesium.Cesium3DTileset): LayerSchemaResult {
    const fieldMap = new Map<string, LayerSchemaField>()

    // 方式1：从 tileset.properties（tileset.json 中的 properties 声明）
    const props = (tileset as any).properties
    if (props && typeof props === 'object') {
      for (const key of Object.keys(props)) {
        const meta = props[key]
        fieldMap.set(key, {
          name: key,
          type: meta?.type ?? (meta?.minimum !== undefined ? 'number' : 'unknown'),
          sample: meta?.minimum ?? meta?.maximum ?? undefined,
        })
      }
    }

    // 方式2：从已加载的 root tile content 的 feature batch table
    const root = tileset.root
    if (root?.content && typeof root.content.featuresLength === 'number' && root.content.featuresLength > 0) {
      const feature = root.content.getFeature(0) as Cesium.Cesium3DTileFeature
      if (feature && typeof feature.getPropertyIds === 'function') {
        const ids = feature.getPropertyIds()
        for (const id of ids) {
          if (fieldMap.has(id)) continue
          const val = feature.getProperty(id)
          fieldMap.set(id, {
            name: id,
            type: val === null || val === undefined ? 'unknown' : Array.isArray(val) ? 'array' : typeof val,
            sample: val,
          })
        }
      }
    }

    // 提取 3D Tiles / Ion 元数据
    const metadata: LayerSchemaResult['metadata'] = {}
    const asset = (tileset as any).asset
    if (asset && typeof asset === 'object') {
      if (asset.version) metadata.assetVersion = String(asset.version)
      if (asset.tilesetVersion) metadata.tilesetVersion = String(asset.tilesetVersion)
    }
    // Cesium Ion 资产 ID（从 IonResource 中提取）
    const resource = (tileset as any).resource ?? (tileset as any)._resource
    if (resource?.ionAssetId) metadata.ionAssetId = resource.ionAssetId
    // 几何误差
    if (tileset.maximumScreenSpaceError != null) metadata.geometricError = tileset.maximumScreenSpaceError
    // 包围球
    if (tileset.boundingSphere) {
      try {
        const center = Cesium.Cartographic.fromCartesian(tileset.boundingSphere.center)
        metadata.boundingSphere = {
          longitude: Cesium.Math.toDegrees(center.longitude),
          latitude: Cesium.Math.toDegrees(center.latitude),
          height: center.height,
          radius: tileset.boundingSphere.radius,
        }
      } catch { /* degenerate bounding sphere */ }
    }
    // extras（tileset.json 中的自定义字段）
    const extras = (tileset as any).extras
    if (extras && typeof extras === 'object' && Object.keys(extras).length > 0) {
      metadata.extras = extras
    }

    return {
      layerId,
      layerName,
      entityCount: -1, // 3D Tiles 不提供精确实体数
      fields: Array.from(fieldMap.values()),
      metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
    }
  }

  clearAll(): { removedLayers: number; removedEntities: number } {
    const removedLayers = this._layers.length
    // 逐个移除所有已注册图层
    const ids = this._layers.map(l => l.id)
    for (const id of ids) {
      this.removeLayer(id)
    }
    // 清除可能残留的非图层实体
    const removedEntities = this._viewer.entities.values.length
    this._viewer.entities.removeAll()
    this._viewer.dataSources.removeAll(true)
    return { removedLayers, removedEntities }
  }

  /** 更新图层列表引用（供外部响应式框架使用） */
  setLayersRef(layers: LayerInfo[]): void {
    this._layers = layers
  }

  // ==================== 3D Scene ====================

  async load3dTiles(params: Load3dTilesParams): Promise<LayerInfo> {
    const { id, name, url, ionAssetId, maximumScreenSpaceError = 16, heightOffset = 0 } = params
    const layerId = id ?? `3dtiles_${Date.now()}`
    const layerName = name ?? '3D Tiles'

    if (!url && !ionAssetId) throw new Error('Either "url" or "ionAssetId" must be provided')

    this.removeLayer(layerId)

    const tileset = ionAssetId
      ? await Cesium.Cesium3DTileset.fromIonAssetId(ionAssetId, { maximumScreenSpaceError })
      : await Cesium.Cesium3DTileset.fromUrl(url!, { maximumScreenSpaceError })

    if (heightOffset !== 0) {
      const cartographic = Cesium.Cartographic.fromCartesian(tileset.boundingSphere.center)
      const surface = Cesium.Cartesian3.fromRadians(
        cartographic.longitude, cartographic.latitude, 0,
      )
      const offset = Cesium.Cartesian3.fromRadians(
        cartographic.longitude, cartographic.latitude, heightOffset,
      )
      const translation = Cesium.Cartesian3.subtract(offset, surface, new Cesium.Cartesian3())
      tileset.modelMatrix = Cesium.Matrix4.fromTranslation(translation)
    }

    this._viewer.scene.primitives.add(tileset)
    this._viewer.flyTo(tileset, { duration: 1.5 })

    const info: LayerInfo = {
      id: layerId,
      name: layerName,
      type: '3D Tiles',
      visible: true,
      color: '#8B5CF6',
    }
    this._cesiumRefs.set(layerId, { tileset })
    this._layers.push(info)
    return info
  }

  // ==================== addGaussianSplat ====================

  async addGaussianSplat(params: AddGaussianSplatParams): Promise<LayerInfo> {
    const { id, name, url, maximumScreenSpaceError = 16, show = true } = params
    const layerId = id ?? `gaussian_splat_${Date.now()}`
    const layerName = name ?? '3D Gaussian Splat'

    this.removeLayer(layerId)

    const tileset = await Cesium.Cesium3DTileset.fromUrl(url, {
      maximumScreenSpaceError,
    })
    tileset.show = show

    this._viewer.scene.primitives.add(tileset)
    this._viewer.flyTo(tileset, { duration: 1.5 })

    const info: LayerInfo = {
      id: layerId,
      name: layerName,
      type: '3d-gaussian-splat',
      visible: show,
      color: '#10B981',
    }
    this._cesiumRefs.set(layerId, { tileset })
    this._layers.push(info)
    return info
  }

  loadTerrain(params: LoadTerrainParams): void {
    const { provider, url, cesiumIonAssetId } = params
    const onError = (e: unknown) => console.error('[CesiumBridge] loadTerrain failed:', e)

    if (provider === 'flat') {
      this._viewer.scene.terrainProvider = new Cesium.EllipsoidTerrainProvider()
    } else if (provider === 'arcgis') {
      Cesium.ArcGISTiledElevationTerrainProvider.fromUrl(
        'https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer',
      ).then((tp: Cesium.TerrainProvider) => {
        this._viewer.scene.terrainProvider = tp
      }).catch(onError)
    } else if (provider === 'cesiumion' && cesiumIonAssetId) {
      Cesium.CesiumTerrainProvider.fromIonAssetId(cesiumIonAssetId).then((tp: Cesium.TerrainProvider) => {
        this._viewer.scene.terrainProvider = tp
      }).catch(onError)
    } else if (url) {
      Cesium.CesiumTerrainProvider.fromUrl(url).then((tp: Cesium.TerrainProvider) => {
        this._viewer.scene.terrainProvider = tp
      }).catch(onError)
    }
  }

  async loadImageryService(params: LoadImageryServiceParams): Promise<LayerInfo> {
    const { id, name, url, ionAssetId, serviceType, layerName, opacity = 1.0 } = params
    const layerId = id ?? `imagery_${Date.now()}`

    if (!url && !ionAssetId) throw new Error('Either "url" or "ionAssetId" must be provided')

    let imageryLayer: Cesium.ImageryLayer

    if (ionAssetId) {
      const provider = await Cesium.IonImageryProvider.fromAssetId(ionAssetId)
      imageryLayer = this._viewer.imageryLayers.addImageryProvider(provider)
    } else {
      let provider: Cesium.ImageryProvider
      switch (serviceType) {
        case 'wms':
          provider = new Cesium.WebMapServiceImageryProvider({
            url: url!,
            layers: layerName ?? '',
          })
          break
        case 'wmts':
          provider = new Cesium.WebMapTileServiceImageryProvider({
            url: url!,
            layer: layerName ?? '',
            style: 'default',
            tileMatrixSetID: 'default028mm',
          })
          break
        case 'arcgis_mapserver':
          provider = new Cesium.ArcGisMapServerImageryProvider({ url: url! } as any)
          break
        case 'xyz':
        default:
          provider = new Cesium.UrlTemplateImageryProvider({
            url: url!,
            maximumLevel: 18,
          })
          break
      }
      imageryLayer = this._viewer.imageryLayers.addImageryProvider(provider)
    }

    imageryLayer.alpha = opacity
    const layerName_ = name ?? (ionAssetId ? `Ion 影像 (${ionAssetId})` : `影像 (${serviceType})`)

    const info: LayerInfo = {
      id: layerId,
      name: layerName_,
      type: '影像',
      visible: true,
      color: '#06B6D4',
    }
    this._cesiumRefs.set(layerId, { imageryLayer })
    this._layers.push(info)
    return info
  }

  // ==================== CZML DataSource ====================

  async loadCzml(params: LoadCzmlParams): Promise<LayerInfo> {
    const { id, name, data, url, sourceUri, clampToGround } = params

    if (!data && !url) throw new Error('Either "data" or "url" must be provided')

    const layerId = id ?? `czml_${Date.now()}`

    // 幂等：先移除同 id 图层
    this.removeLayer(layerId)

    const loadOptions: { sourceUri?: string } = {}
    if (sourceUri) loadOptions.sourceUri = sourceUri

    const ds = await Cesium.CzmlDataSource.load(url ?? data!, loadOptions)

    const displayName = name || ds.name || (url ? `CZML (${url.split('/').pop()})` : 'CZML Data')

    if (clampToGround) {
      for (const entity of ds.entities.values) {
        if (entity.billboard) {
          entity.billboard.heightReference = new Cesium.ConstantProperty(Cesium.HeightReference.CLAMP_TO_GROUND)
        }
        if (entity.point) {
          entity.point.heightReference = new Cesium.ConstantProperty(Cesium.HeightReference.CLAMP_TO_GROUND)
        }
        if (entity.label) {
          entity.label.heightReference = new Cesium.ConstantProperty(Cesium.HeightReference.CLAMP_TO_GROUND)
        }
        if (entity.model) {
          entity.model.heightReference = new Cesium.ConstantProperty(Cesium.HeightReference.CLAMP_TO_GROUND)
        }
      }
    }

    this._viewer.dataSources.add(ds)

    if (params.flyTo !== false) {
      this._viewer.flyTo(ds, { duration: 1.5 })
    }

    const info: LayerInfo = {
      id: layerId,
      name: displayName,
      type: 'CZML',
      visible: true,
      color: '#8B5CF6',
    }
    this._cesiumRefs.set(layerId, { dataSource: ds })
    this._layers.push(info)
    return info
  }

  // ==================== KML/KMZ DataSource ====================

  async loadKml(params: LoadKmlParams): Promise<LayerInfo> {
    const { id, name, url, data, sourceUri, clampToGround } = params

    if (!url && !data) throw new Error('Either "url" or "data" must be provided')

    const layerId = id ?? `kml_${Date.now()}`

    // 幂等：先移除同 id 图层
    this.removeLayer(layerId)

    const loadOptions: Record<string, unknown> = {
      camera: this._viewer.scene.camera,
      canvas: this._viewer.scene.canvas,
    }
    if (sourceUri) loadOptions.sourceUri = sourceUri
    if (clampToGround) loadOptions.clampToGround = true

    const source = url ?? new Blob([data!], { type: 'application/xml' })
    const ds = await Cesium.KmlDataSource.load(source, loadOptions)

    const displayName = name || ds.name || (url ? `KML (${url.split('/').pop()})` : 'KML Data')

    this._viewer.dataSources.add(ds)

    if (params.flyTo !== false) {
      this._viewer.flyTo(ds, { duration: 1.5 })
    }

    const info: LayerInfo = {
      id: layerId,
      name: displayName,
      type: 'KML',
      visible: true,
      color: '#F59E0B',
    }
    this._cesiumRefs.set(layerId, { dataSource: ds })
    this._layers.push(info)
    return info
  }

  // ==================== Basemap ====================

  setBasemap(params: SetBasemapParams): string {
    const basemap = params.basemap ?? 'dark'
    this._viewer.imageryLayers.removeAll()

    // Custom URL template takes priority
    if (params.url) {
      this._viewer.imageryLayers.addImageryProvider(
        new Cesium.UrlTemplateImageryProvider({ url: params.url, maximumLevel: 18 }),
      )
      return params.url
    }

    const tk = params.token ?? ''
    const preset = BASEMAP_PRESETS[basemap] ?? BASEMAP_PRESETS['dark']!
    for (const layer of preset.layers(tk)) {
      this._viewer.imageryLayers.addImageryProvider(
        new Cesium.UrlTemplateImageryProvider(layer),
      )
    }
    if (preset.backgroundColor) {
      this._viewer.scene.backgroundColor = parseColor(preset.backgroundColor)
      this._viewer.scene.globe.baseColor = parseColor(preset.backgroundColor)
    }
    return basemap
  }
}

// ==================== Helpers ====================

const DEFAULT_LAYER_COLOR = '#3B82F6'
// 贴地 polygon 填充比描边更透明，避免压住底图
const POLYGON_FILL_ALPHA_RATIO = 0.4

const IMAGERY_LAYER_STYLE_KEYS = [
  'alpha',
  'brightness',
  'contrast',
  'hue',
  'saturation',
  'gamma',
] as const

function isFiniteNumber(value: unknown): value is number {
  return typeof value === 'number' && Number.isFinite(value)
}

function isInRange(value: number, min: number, max: number): boolean {
  return value >= min && value <= max
}

function countThematicStyles(style: LayerStyle): number {
  return [
    style.choropleth !== undefined,
    style.category !== undefined,
    style.randomColor === true,
    style.gradient !== undefined,
  ].filter(Boolean).length
}

function hasThematicStyle(style: LayerStyle): boolean {
  return countThematicStyles(style) > 0
}

function isValidLayerStyleForUpdate(style: LayerStyle): boolean {
  if (countThematicStyles(style) > 1) return false
  if (style.opacity !== undefined && (!isFiniteNumber(style.opacity) || !isInRange(style.opacity, 0, 1))) return false
  if (style.pointSize !== undefined && (!isFiniteNumber(style.pointSize) || style.pointSize < 0)) return false
  if (style.strokeWidth !== undefined && (!isFiniteNumber(style.strokeWidth) || style.strokeWidth < 0)) return false

  if (style.gradient !== undefined && style.gradient.length !== 2) return false
  if (style.category !== undefined && !style.category.field) return false

  const choropleth = style.choropleth
  if (choropleth) {
    if (!choropleth.field || choropleth.breaks.length < 2) return false
    if (choropleth.colors.length !== choropleth.breaks.length - 1) return false
    for (let i = 0; i < choropleth.breaks.length; i++) {
      const current = choropleth.breaks[i]!
      if (!isFiniteNumber(current)) return false
      if (i > 0 && current <= choropleth.breaks[i - 1]!) return false
    }
  }

  return true
}

function applyBasicLayerStyle(
  entities: Cesium.Entity[],
  style: LayerStyle,
  fallbackColor: string,
  polygonOutlines?: Map<Cesium.Entity, Cesium.Entity[]>,
) {
  const shouldUpdateColor = style.color !== undefined || style.opacity !== undefined
  const baseColor = shouldUpdateColor ? parseColor(style.color ?? fallbackColor) : undefined
  const alpha = style.opacity ?? baseColor?.alpha
  const fillColor = baseColor && alpha !== undefined ? baseColor.withAlpha(alpha) : undefined
  const polygonFillColor = fillColor ? baseColor!.withAlpha(alpha! * POLYGON_FILL_ALPHA_RATIO) : undefined

  // 同一图层所有实体共用样式值，材质/属性实例只构造一次复用
  const lineMaterial = fillColor ? new Cesium.ColorMaterialProperty(fillColor) : undefined
  const fillMaterial = polygonFillColor ? new Cesium.ColorMaterialProperty(polygonFillColor) : undefined
  const fillColorProp = fillColor ? new Cesium.ConstantProperty(fillColor) : undefined
  const strokeWidthProp = style.strokeWidth !== undefined ? new Cesium.ConstantProperty(style.strokeWidth) : undefined
  const billboardSizeProp = style.pointSize !== undefined ? new Cesium.ConstantProperty(style.pointSize * 2) : undefined
  const pointSizeProp = style.pointSize !== undefined ? new Cesium.ConstantProperty(style.pointSize) : undefined

  for (const entity of entities) {
    if (entity.polyline) {
      if (lineMaterial) entity.polyline.material = lineMaterial
      if (strokeWidthProp) entity.polyline.width = strokeWidthProp
    }
    if (entity.polygon) {
      if (fillMaterial && fillColorProp) {
        entity.polygon.material = fillMaterial
        entity.polygon.outlineColor = fillColorProp
      }
      syncPolygonOutlines(entity, fillColor, style.strokeWidth, polygonOutlines)
    }
    if (entity.billboard) {
      if (fillColorProp) entity.billboard.color = fillColorProp as any
      if (billboardSizeProp) {
        entity.billboard.width = billboardSizeProp as any
        entity.billboard.height = billboardSizeProp as any
      }
    }
    if (entity.point) {
      if (fillColorProp) entity.point.color = fillColorProp
      if (pointSizeProp) entity.point.pixelSize = pointSizeProp
    }
  }
}

function applyThematicLayerStyle(
  style: LayerStyle,
  entities: Cesium.Entity[],
  polygonOutlines?: Map<Cesium.Entity, Cesium.Entity[]>,
) {
  const opacity = style.opacity ?? 0.6
  const choropleth = style.choropleth
  if (choropleth) {
    applyChoroplethStyle(entities, choropleth.field, choropleth.breaks, choropleth.colors, opacity, polygonOutlines)
    return
  }

  const category = style.category
  if (category) {
    applyCategoryStyle(entities, category.field, category.colors, opacity, polygonOutlines)
    return
  }

  if (style.randomColor) {
    applyRandomColorStyle(entities, opacity, polygonOutlines)
    return
  }

  if (style.gradient) {
    applyGradientStyle(entities, style.gradient, opacity, polygonOutlines)
  }
}

function isValidImageryLayerStyle(style: ImageryLayerStyle): boolean {
  const hasField = IMAGERY_LAYER_STYLE_KEYS.some(key => style[key] !== undefined)
  if (!hasField) return false

  for (const key of IMAGERY_LAYER_STYLE_KEYS) {
    const value = style[key]
    if (value === undefined) continue
    if (!isFiniteNumber(value)) return false
    if (key === 'alpha' && !isInRange(value, 0, 1)) return false
  }

  return true
}

function applyImageryLayerStyle(layer: Cesium.ImageryLayer, style: ImageryLayerStyle) {
  for (const key of IMAGERY_LAYER_STYLE_KEYS) {
    const value = style[key]
    if (value !== undefined) {
      (layer as any)[key] = value
    }
  }
}

function isValidPrimitiveLayerStyle(style: PrimitiveLayerStyle): boolean {
  const hasField = [
    style.color,
    style.opacity,
    style.outlineColor,
    style.outlineWidth,
    style.pointSize,
    style.lineWidth,
  ].some(value => value !== undefined)
  if (!hasField) return false
  if (style.opacity !== undefined && (!isFiniteNumber(style.opacity) || !isInRange(style.opacity, 0, 1))) return false
  if (style.outlineWidth !== undefined && (!isFiniteNumber(style.outlineWidth) || !isInRange(style.outlineWidth, 0, 255))) return false
  if (style.pointSize !== undefined && (!isFiniteNumber(style.pointSize) || !isInRange(style.pointSize, 0, 255))) return false
  if (style.lineWidth !== undefined && (!isFiniteNumber(style.lineWidth) || !isInRange(style.lineWidth, 0, 255))) return false
  return true
}

function applyGeoJsonPrimitiveStyle(primitive: any, style: PrimitiveLayerStyle) {
  const C = Cesium as any
  if (primitive.points && C.BufferPoint && C.BufferPointMaterial) {
    applyPrimitiveCollectionStyle(
      primitive.points,
      new C.BufferPoint(),
      new C.BufferPointMaterial(),
      style,
      'point',
    )
  }
  if (primitive.polylines && C.BufferPolyline && C.BufferPolylineMaterial) {
    applyPrimitiveCollectionStyle(
      primitive.polylines,
      new C.BufferPolyline(),
      new C.BufferPolylineMaterial(),
      style,
      'polyline',
    )
  }
  if (primitive.polygons && C.BufferPolygon && C.BufferPolygonMaterial) {
    applyPrimitiveCollectionStyle(
      primitive.polygons,
      new C.BufferPolygon(),
      new C.BufferPolygonMaterial(),
      style,
      'polygon',
    )
  }
}

function applyPrimitiveCollectionStyle(
  collection: any,
  element: any,
  material: any,
  style: PrimitiveLayerStyle,
  target: 'point' | 'polyline' | 'polygon',
) {
  const color = style.color ? parseColor(style.color) : undefined
  const outlineColor = style.outlineColor ? parseColor(style.outlineColor) : undefined
  const count = collection.primitiveCount ?? 0

  for (let i = 0; i < count; i++) {
    collection.get(i, element)
    element.getMaterial(material)

    if (color) {
      material.color = color.withAlpha(style.opacity ?? color.alpha)
    } else if (style.opacity !== undefined && material.color) {
      material.color = material.color.withAlpha(style.opacity)
    }
    if (outlineColor) material.outlineColor = outlineColor
    if (style.outlineWidth !== undefined) material.outlineWidth = style.outlineWidth
    if (target === 'point' && style.pointSize !== undefined) material.size = style.pointSize
    if (target === 'polyline' && style.lineWidth !== undefined) material.width = style.lineWidth

    element.setMaterial(material)
  }
}

function applyChoroplethStyle(
  entities: Cesium.Entity[],
  field: string,
  breaks: number[],
  colors: string[],
  opacity: number,
  polygonOutlines?: Map<Cesium.Entity, Cesium.Entity[]>,
) {
  const fillColors = colors.map(c => parseColor(c ?? DEFAULT_LAYER_COLOR).withAlpha(opacity))
  const strokeColor = parseColor('#333333').withAlpha(0.6)
  for (const entity of entities) {
    const props = entity.properties
    if (!props) continue
    const raw = props[field]?.getValue(Cesium.JulianDate.now())
    const val = typeof raw === 'number' ? raw : parseFloat(raw)
    if (isNaN(val)) continue

    let classIdx = colors.length - 1
    for (let i = 1; i < breaks.length; i++) {
      if (val <= breaks[i]!) {
        classIdx = Math.min(i - 1, colors.length - 1)
        break
      }
    }
    applyColorToEntity(entity, fillColors[classIdx]!, strokeColor, polygonOutlines)
  }
}

// 聚类/分类着色的默认调色板（12色，对色觉友好）
const CATEGORY_PALETTE = [
  '#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6',
  '#EC4899', '#06B6D4', '#F97316', '#6366F1', '#14B8A6',
  '#E11D48', '#84CC16',
]

function getCategoryIndex(raw: unknown, categoryIndexes: Map<string, number>): number {
  if (raw === undefined || raw === null) return -1
  if (typeof raw === 'number' && Number.isFinite(raw)) return Math.trunc(raw)

  const text = String(raw).trim()
  if (!text) return -1

  const numeric = Number(text)
  if (Number.isFinite(numeric)) return Math.trunc(numeric)

  if (!categoryIndexes.has(text)) {
    categoryIndexes.set(text, categoryIndexes.size)
  }
  return categoryIndexes.get(text)!
}

function applyCategoryStyle(
  entities: Cesium.Entity[],
  field: string,
  customColors: string[] | undefined,
  opacity: number,
  polygonOutlines?: Map<Cesium.Entity, Cesium.Entity[]>,
) {
  const palette = customColors?.length ? customColors : CATEGORY_PALETTE
  const fillColors = palette.map(c => parseColor(c).withAlpha(opacity))
  const strokeColors = palette.map(c => parseColor(c).withAlpha(Math.min(opacity + 0.2, 1)))
  const noiseFill = parseColor('#6B7280').withAlpha(opacity)
  const noiseStroke = parseColor('#6B7280').withAlpha(Math.min(opacity + 0.2, 1))
  const categoryIndexes = new Map<string, number>()
  for (const entity of entities) {
    const props = entity.properties
    if (!props) continue
    const raw = props[field]?.getValue(Cesium.JulianDate.now())
    const val = getCategoryIndex(raw, categoryIndexes)
    // -1 (DBSCAN 噪声点) → 灰色
    const idx = val < 0 ? -1 : val % palette.length
    const fillColor = idx < 0 ? noiseFill : fillColors[idx]!
    const strokeColor = idx < 0 ? noiseStroke : strokeColors[idx]!

    applyColorToEntity(entity, fillColor, strokeColor, polygonOutlines)
  }
}

function applyRandomColorStyle(
  entities: Cesium.Entity[],
  opacity: number,
  polygonOutlines?: Map<Cesium.Entity, Cesium.Entity[]>,
) {
  for (const entity of entities) {
    const hue = Math.random()
    const sat = 0.5 + Math.random() * 0.4
    const light = 0.4 + Math.random() * 0.25
    const fillColor = Cesium.Color.fromHsl(hue, sat, light, opacity)
    const strokeColor = Cesium.Color.fromHsl(hue, sat, light, Math.min(opacity + 0.3, 1))
    applyColorToEntity(entity, fillColor, strokeColor, polygonOutlines)
  }
}

function applyGradientStyle(
  entities: Cesium.Entity[],
  gradient: [string, string],
  opacity: number,
  polygonOutlines?: Map<Cesium.Entity, Cesium.Entity[]>,
) {
  const startColor = parseColor(gradient[0])
  const endColor = parseColor(gradient[1])
  const total = Math.max(entities.length - 1, 1)
  for (let i = 0; i < entities.length; i++) {
    const t = i / total
    const r = startColor.red + (endColor.red - startColor.red) * t
    const g = startColor.green + (endColor.green - startColor.green) * t
    const b = startColor.blue + (endColor.blue - startColor.blue) * t
    const fillColor = new Cesium.Color(r, g, b, opacity)
    const strokeColor = new Cesium.Color(r, g, b, Math.min(opacity + 0.3, 1))
    applyColorToEntity(entities[i]!, fillColor, strokeColor, polygonOutlines)
  }
}

function applyColorToEntity(
  entity: Cesium.Entity,
  fillColor: Cesium.Color,
  strokeColor: Cesium.Color,
  polygonOutlines?: Map<Cesium.Entity, Cesium.Entity[]>,
) {
  if (entity.polygon) {
    entity.polygon.material = new Cesium.ColorMaterialProperty(fillColor)
    entity.polygon.outlineColor = new Cesium.ConstantProperty(strokeColor)
    syncPolygonOutlines(entity, strokeColor, undefined, polygonOutlines)
  } else if (entity.polyline) {
    entity.polyline.material = new Cesium.ColorMaterialProperty(fillColor)
  } else if (entity.point) {
    entity.point.color = new Cesium.ConstantProperty(fillColor)
  } else if (entity.billboard) {
    entity.billboard.color = new Cesium.ConstantProperty(fillColor)
  }
}

function syncPolygonOutlines(
  entity: Cesium.Entity,
  strokeColor: Cesium.Color | undefined,
  strokeWidth: number | undefined,
  polygonOutlines?: Map<Cesium.Entity, Cesium.Entity[]>,
) {
  const outlines = polygonOutlines?.get(entity)
  if (!outlines) return

  for (const outline of outlines) {
    if (!outline.polyline) continue
    if (strokeColor) outline.polyline.material = new Cesium.ColorMaterialProperty(strokeColor)
    if (strokeWidth !== undefined) outline.polyline.width = new Cesium.ConstantProperty(strokeWidth)
  }
}

export function detectGeometryType(geojson: Record<string, unknown>): string {
  const features = (geojson as any)?.features
  if (!features?.length) return '未知'
  const t: string = features[0]?.geometry?.type ?? ''
  if (t.includes('Point')) return '点'
  if (t.includes('Line')) return '线'
  if (t.includes('Polygon')) return '面'
  return t || '未知'
}

function detectGeometryTypeFromDataSource(ds: Cesium.GeoJsonDataSource): string {
  const entities = ds.entities.values
  if (!entities.length) return '未知'
  const e = entities[0]!
  if (e.point || e.billboard) return '点'
  if (e.polyline) return '线'
  if (e.polygon) return '面'
  return '未知'
}

/** 生成圆点 canvas 图片（用于替换默认 pin 图标） */
function createCircleImage(size: number, cssColor: string, alpha: number): HTMLCanvasElement {
  const dpr = Math.min(window.devicePixelRatio || 1, 2)
  const px = Math.round(size * dpr)
  const canvas = document.createElement('canvas')
  canvas.width = px
  canvas.height = px
  const ctx = canvas.getContext('2d')!
  const r = px / 2
  // 填充
  ctx.beginPath()
  ctx.arc(r, r, r - 2 * dpr, 0, Math.PI * 2)
  const c = parseColor(cssColor).withAlpha(alpha)
  ctx.fillStyle = `rgba(${Math.round(c.red * 255)},${Math.round(c.green * 255)},${Math.round(c.blue * 255)},${c.alpha})`
  ctx.fill()
  // 白色描边
  ctx.strokeStyle = 'rgba(255,255,255,0.9)'
  ctx.lineWidth = 1.5 * dpr
  ctx.stroke()
  return canvas
}

/** 使用 heatmap.js 生成热力图 canvas */
function generateHeatmapCanvas(
  points: Array<{ lon: number; lat: number; weight: number }>,
  radius: number,
  bounds: { west: number; south: number; east: number; north: number },
  opts: {
    gradient?: Record<number, string>,
    blur?: number,
    maxOpacity?: number,
    minOpacity?: number,
    resolution?: number,
  } = {},
): HTMLCanvasElement | null {
  if (!points.length) return null

  const SIZE = opts.resolution ?? 512
  const { west, south, east, north } = bounds
  const w = east - west
  const h = north - south

  // 创建临时容器
  const container = document.createElement('div')
  container.style.cssText = `width:${SIZE}px;height:${SIZE}px;position:fixed;left:-9999px;top:-9999px;`
  document.body.appendChild(container)

  try {
    const heatmapInstance = h337.create({
      container,
      radius: Math.max(radius, 10),
      maxOpacity: opts.maxOpacity ?? 0.8,
      minOpacity: opts.minOpacity ?? 0,
      blur: opts.blur ?? 0.85,
      gradient: opts.gradient ?? { 0.25: 'rgb(0,0,255)', 0.55: 'rgb(0,255,0)', 0.85: 'yellow', 1.0: 'rgb(255,0,0)' },
    })

    const maxW = Math.max(...points.map(p => p.weight))
    const data = points.map(p => ({
      x: Math.round(((p.lon - west) / w) * SIZE),
      y: Math.round(((north - p.lat) / h) * SIZE),
      value: p.weight,
    }))

    heatmapInstance.setData({ max: maxW || 1, data })

    // 从 heatmap.js 容器中提取 canvas
    const heatCanvas = container.querySelector('canvas')
    if (!heatCanvas) return null

    // 复制到独立 canvas（脱离 heatmap.js 容器引用）
    const result = document.createElement('canvas')
    result.width = SIZE
    result.height = SIZE
    const ctx = result.getContext('2d')
    if (!ctx) return null
    ctx.drawImage(heatCanvas, 0, 0)
    return result
  } finally {
    document.body.removeChild(container)
  }
}
