<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { fallbackMapSource } from '../mapSource'
import type { MapBoundsChangePayload, MapClusterNode, MapNode, MapRenderable, PublicMapTileSource } from '../types'

const props = withDefaults(defineProps<{
  items: MapRenderable[]
  selectedNodeId: string | null
  isAdmin: boolean
  autoFit?: boolean
  loading?: boolean
  mapSource?: PublicMapTileSource
  mapSources?: PublicMapTileSource[]
}>(), {
  autoFit: true,
  loading: false,
  mapSource: () => fallbackMapSource,
  mapSources: () => [fallbackMapSource],
})

const emit = defineEmits<{
  'select-node': [nodeId: string]
  'clear-node': []
  'delete-node': [nodeId: string]
  'purge-node': [nodeId: string]
  'delete-displayed-nodes': []
  'delete-and-block-node': [payload: { nodeId: string; nodeNum: number | null }]
  'bounds-change': [payload: MapBoundsChangePayload]
  'map-source-change': [sourceId: number]
}>()

const mapEl = ref<HTMLElement | null>(null)
const menuNode = ref<MapNode | null>(null)
const menuMap = ref(false)
const menuX = ref(0)
const menuY = ref(0)
const lastRaisedNodeId = ref<string | null>(null)
let map: L.Map | null = null
let tileLayer: L.TileLayer | null = null
let markerLayer: L.LayerGroup | null = null
const markersByKey = new Map<string, L.Marker>()
const overlapShuffleOrders = new Map<string, string[]>()
const shuffledSelectedNodeIds = new Set<string>()
let hasFitBounds = false

const minMapZoom = 3
const defaultMapCenter: L.LatLngExpression = [35.8617, 104.1954]
const defaultMapZoom = 4
const worldBounds = L.latLngBounds(
  [-85.05112878, -180],
  [85.05112878, 180],
)

onMounted(async () => {
  window.addEventListener('click', closeNodeMenu)
  window.addEventListener('keydown', handleKeydown)
  await nextTick()
  if (!mapEl.value) {
    return
  }
  map = L.map(mapEl.value, {
    zoomControl: true,
    minZoom: minMapZoom,
    maxBounds: worldBounds,
    maxBoundsViscosity: 1.0,
    worldCopyJump: false,
  }).setView(defaultMapCenter, defaultMapZoom)
  map.attributionControl.setPrefix(false)
  applyTileLayer()
  map.on('click', () => {
    closeNodeMenu()
    overlapShuffleOrders.clear()
    shuffledSelectedNodeIds.clear()
    emit('clear-node')
  })
  // 地图空白处右键：在管理员视图下展示「删除所有显示的节点」入口。
  // 必须在原生 contextmenu 上 preventDefault，Leaflet 的事件包装层不会阻止浏览器默认菜单。
  mapEl.value.addEventListener('contextmenu', handleMapContextMenu)
  map.on('contextmenu', (event) => {
    L.DomEvent.stopPropagation(event)
  })
  map.on('moveend', emitBoundsChange)
  markerLayer = L.layerGroup().addTo(map)
  renderMarkers(true)
  emitBoundsChange()
})

onBeforeUnmount(() => {
  window.removeEventListener('click', closeNodeMenu)
  window.removeEventListener('keydown', handleKeydown)
  mapEl.value?.removeEventListener('contextmenu', handleMapContextMenu)
  map?.remove()
  map = null
  tileLayer = null
  markerLayer = null
  markersByKey.clear()
  overlapShuffleOrders.clear()
  shuffledSelectedNodeIds.clear()
})

watch(
  () => [props.items, props.selectedNodeId] as const,
  () => renderMarkers(false),
  { deep: true },
)

watch(
  () => props.mapSource,
  () => applyTileLayer(),
  { deep: true },
)

function selectMapSource(sourceId: number) {
  emit('map-source-change', sourceId)
}

function applyTileLayer() {
  if (!map) {
    return
  }
  if (tileLayer) {
    tileLayer.remove()
  }
  tileLayer = L.tileLayer(props.mapSource.url_template, {
    minZoom: minMapZoom,
    maxZoom: props.mapSource.max_zoom || fallbackMapSource.max_zoom,
    noWrap: true,
    bounds: worldBounds,
    attribution: props.mapSource.attribution || fallbackMapSource.attribution,
  }).addTo(map)
}

function closeNodeMenu() {
  menuNode.value = null
  menuMap.value = false
}

function openMapMenu(event: MouseEvent) {
  if (!props.isAdmin) {
    return
  }
  menuNode.value = null
  menuMap.value = true
  menuX.value = event.clientX
  menuY.value = event.clientY
}

function handleMapContextMenu(event: MouseEvent) {
  // 总是阻止浏览器默认菜单。即使非管理员也阻止，避免在 marker 上右键漏到地图后又弹默认菜单。
  event.preventDefault()
  // marker 自身已通过 marker.on('contextmenu') 处理；这里仅响应空白处。
  const target = event.target as HTMLElement | null
  if (target?.closest('.leaflet-marker-icon, .leaflet-popup, .map-source-control, .context-menu')) {
    return
  }
  openMapMenu(event)
}

function deleteAllDisplayedNodes() {
  emit('delete-displayed-nodes')
  closeNodeMenu()
}

function nodeDetailHref(nodeId: string): string {
  return `/detailed/${encodeURIComponent(nodeId)}`
}

function openNodeMenu(node: MapNode, event: L.LeafletMouseEvent) {
  L.DomEvent.stopPropagation(event)
  lastRaisedNodeId.value = node.node_id
  emit('select-node', node.node_id)
  menuNode.value = node
  menuX.value = event.originalEvent.clientX
  menuY.value = event.originalEvent.clientY
}

function deleteSelectedNode() {
  if (menuNode.value) {
    emit('delete-node', menuNode.value.node_id)
  }
  closeNodeMenu()
}

function purgeSelectedNode() {
  if (menuNode.value) {
    emit('purge-node', menuNode.value.node_id)
  }
  closeNodeMenu()
}

function deleteAndBlockSelectedNode() {
  if (menuNode.value) {
    emit('delete-and-block-node', {
      nodeId: menuNode.value.node_id,
      nodeNum: menuNode.value.map_report?.node_num ?? menuNode.value.nodeinfo?.node_num ?? null,
    })
  }
  closeNodeMenu()
}

function handleKeydown(event: KeyboardEvent) {
  if (event.key === 'Escape') {
    closeNodeMenu()
  }
}

function emitBoundsChange() {
  if (!map) {
    return
  }
  const bounds = map.getBounds()
  emit('bounds-change', {
    bounds: {
      min_lat: clamp(bounds.getSouth(), -90, 90),
      max_lat: clamp(bounds.getNorth(), -90, 90),
      min_lng: normalizeLongitude(bounds.getWest()),
      max_lng: normalizeLongitude(bounds.getEast()),
    },
    zoom: map.getZoom(),
  })
}

function renderMarkers(forceFit: boolean) {
  if (!map || !markerLayer) {
    return
  }
  const bounds = L.latLngBounds([])
  const visibleMarkerKeys = new Set<string>()
  const overlapGroups = buildOverlapGroups(props.items)

  for (const item of props.items) {
    const markerKey = mapMarkerKey(item)
    visibleMarkerKeys.add(markerKey)

    if (item.type === 'cluster') {
      const existingMarker = markersByKey.get(markerKey)
      if (!existingMarker) {
        const marker = buildClusterMarker(item)
        marker.addTo(markerLayer)
        markersByKey.set(markerKey, marker)
      }
      bounds.extend([item.latitude, item.longitude])
      continue
    }

    const node = item
    const rawSelected = node.node_id === props.selectedNodeId
    const shuffledSelected = rawSelected && shuffledSelectedNodeIds.has(node.node_id)
    const selected = rawSelected && !shuffledSelected
    const overlapGroupKey = nodeOverlapGroupKey(node, overlapGroups)
    const overlapGroup = overlapGroupKey ? overlapGroups.get(overlapGroupKey) : undefined
    const overlapIndex = overlapGroup ? nodeOverlapIndex(node, overlapGroup) : 0
    const raised = selected || node.node_id === lastRaisedNodeId.value
    const zIndexOffset = raised ? 1000 : overlapIndex
    const nodeIcon = L.divIcon({
      className: `node-marker${selected ? ' selected' : ''}`,
      html: `<span style="--node-color: ${nodeColor(node.node_id)}">${escapeHTML(node.label || 'N')}</span>`,
      iconSize: [34, 22],
      iconAnchor: [17, 11],
    })
    let marker = markersByKey.get(markerKey)

    if (!marker) {
      marker = L.marker([node.latitude, node.longitude], {
        icon: nodeIcon,
        title: node.label,
        zIndexOffset,
      })
      marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' })
      marker.addTo(markerLayer)
      markersByKey.set(markerKey, marker)
    } else {
      marker.setLatLng([node.latitude, node.longitude])
      marker.setIcon(nodeIcon)
      marker.setZIndexOffset(zIndexOffset)
      marker.options.title = node.label
      marker.getElement()?.setAttribute('title', node.label)
      const popup = marker.getPopup()
      if (popup) {
        popup.setContent(buildNodePopupHTML(node))
      } else {
        marker.bindPopup(buildNodePopupHTML(node), { maxWidth: 320, className: 'node-detail-popup' })
      }
    }

    marker.off('click')
    marker.off('contextmenu')
    marker.on('click', (event) => {
      L.DomEvent.stopPropagation(event)
      closeNodeMenu()
      if (node.node_id === props.selectedNodeId) {
        if (moveSelectedNodeBehindOverlap(node, overlapGroups)) {
          shuffledSelectedNodeIds.add(node.node_id)
          marker?.closePopup()
          emit('clear-node')
          renderMarkers(false)
        }
        return
      }
      shuffledSelectedNodeIds.clear()
      lastRaisedNodeId.value = node.node_id
      emit('select-node', node.node_id)
    })
    marker.on('contextmenu', (event) => openNodeMenu(node, event))

    if (selected && !marker.getPopup()?.isOpen()) {
      marker.openPopup()
    }
    bounds.extend([node.latitude, node.longitude])
  }

  for (const [markerKey, marker] of markersByKey) {
    if (!visibleMarkerKeys.has(markerKey)) {
      markerLayer.removeLayer(marker)
      markersByKey.delete(markerKey)
    }
  }

  if (props.autoFit && props.items.length > 0 && (forceFit || !hasFitBounds)) {
    map.fitBounds(bounds, { padding: [24, 24], maxZoom: 13 })
    hasFitBounds = true
  }
}

function mapMarkerKey(item: MapRenderable): string {
  if (item.type === 'cluster') {
    return `cluster:${item.latitude}:${item.longitude}:${item.count}`
  }
  return `node:${item.node_id}`
}

function buildOverlapGroups(items: MapRenderable[]): Map<string, string[]> {
  const groups = new Map<string, string[]>()
  if (!map) {
    return groups
  }

  const capsules = items
    .filter((item): item is MapNode => item.type !== 'cluster')
    .map((node) => ({ node, bounds: nodeCapsuleBounds(node) }))
  const visited = new Set<string>()

  for (const capsule of capsules) {
    if (visited.has(capsule.node.node_id)) {
      continue
    }

    const stack = [capsule]
    const group: string[] = []
    visited.add(capsule.node.node_id)

    while (stack.length > 0) {
      const current = stack.pop()
      if (!current) {
        continue
      }
      group.push(current.node.node_id)

      for (const candidate of capsules) {
        if (visited.has(candidate.node.node_id)) {
          continue
        }
        if (capsuleBoundsOverlap(current.bounds, candidate.bounds)) {
          visited.add(candidate.node.node_id)
          stack.push(candidate)
        }
      }
    }

    if (group.length >= 2) {
      const key = overlapGroupKey(group)
      const existingOrder = overlapShuffleOrders.get(key) ?? []
      const activeIds = new Set(group)
      const ordered = existingOrder.filter((nodeId) => activeIds.has(nodeId))
      for (const nodeId of group) {
        if (!ordered.includes(nodeId)) {
          ordered.push(nodeId)
        }
      }
      overlapShuffleOrders.set(key, ordered)
      groups.set(key, ordered)
    }
  }

  for (const key of overlapShuffleOrders.keys()) {
    if (!groups.has(key)) {
      overlapShuffleOrders.delete(key)
    }
  }

  return groups
}

function nodeOverlapGroupKey(node: MapNode, overlapGroups: Map<string, string[]>): string | null {
  for (const [key, nodeIds] of overlapGroups) {
    if (nodeIds.includes(node.node_id)) {
      return key
    }
  }
  return null
}

function nodeOverlapIndex(node: MapNode, group: string[]): number {
  const index = group.indexOf(node.node_id)
  return index === -1 ? 0 : index
}

function moveSelectedNodeBehindOverlap(node: MapNode, overlapGroups: Map<string, string[]>): boolean {
  const groupKey = nodeOverlapGroupKey(node, overlapGroups)
  if (!groupKey) {
    return false
  }
  const group = overlapGroups.get(groupKey)
  if (!group || group.length < 2) {
    return false
  }

  const nextOrder = [node.node_id, ...group.filter((nodeId) => nodeId !== node.node_id)]
  overlapShuffleOrders.set(groupKey, nextOrder)
  lastRaisedNodeId.value = null
  return true
}

function overlapGroupKey(nodeIds: string[]): string {
  return [...nodeIds].sort().join('|')
}

function nodeCapsuleBounds(node: MapNode): { left: number; right: number; top: number; bottom: number } {
  const point = map!.latLngToLayerPoint([node.latitude, node.longitude])
  const width = nodeCapsuleWidth(node)
  const height = 22
  return {
    left: point.x - width / 2,
    right: point.x + width / 2,
    top: point.y - height / 2,
    bottom: point.y + height / 2,
  }
}

function nodeCapsuleWidth(node: MapNode): number {
  const label = node.label || 'N'
  return Math.max(34, Math.ceil(label.length * 6 + 10))
}

function capsuleBoundsOverlap(
  left: { left: number; right: number; top: number; bottom: number },
  right: { left: number; right: number; top: number; bottom: number },
): boolean {
  return left.left <= right.right && left.right >= right.left && left.top <= right.bottom && left.bottom >= right.top
}

function buildClusterMarker(cluster: MapClusterNode): L.Marker {
  const size = clusterIconSize(cluster.count)
  const marker = L.marker([cluster.latitude, cluster.longitude], {
    icon: L.divIcon({
      className: `cluster-marker ${clusterClass(cluster.count)}`,
      html: `<span>${formatCount(cluster.count)}</span>`,
      iconSize: [size, size],
      iconAnchor: [size / 2, size / 2],
    }),
    title: `${cluster.count} 个坐标`,
  })
  marker.bindPopup(buildClusterPopupHTML(cluster), { maxWidth: 260, className: 'node-detail-popup' })
  marker.on('click', () => {
    closeNodeMenu()
    if (map) {
      map.setView([cluster.latitude, cluster.longitude], Math.max(minMapZoom, Math.min(map.getZoom() + 2, map.getMaxZoom())))
    }
  })
  return marker
}

function buildNodePopupHTML(node: MapNode): string {
  const info = node.nodeinfo
  const report = node.map_report
  return `
    <div class="node-popup">
      <strong>${escapeHTML(node.node_id)}</strong>
      <dl>
        <div><dt>长名称</dt><dd>${escapeHTML(report?.long_name || info?.long_name || '-')}</dd></div>
        <div><dt>短名称</dt><dd>${escapeHTML(report?.short_name || info?.short_name || '-')}</dd></div>
        <div><dt>硬件型号</dt><dd>${escapeHTML(report?.hw_model || info?.hw_model || '-')}</dd></div>
        <div><dt>角色</dt><dd>${escapeHTML(report?.role || info?.role || '-')}</dd></div>
        <div><dt>固件版本</dt><dd>${escapeHTML(report?.firmware_version || '-')}</dd></div>
        <div><dt>区域</dt><dd>${escapeHTML(report?.region || '-')}</dd></div>
        <div><dt>调制预设</dt><dd>${escapeHTML(report?.modem_preset || '-')}</dd></div>
        <div><dt>海拔</dt><dd>${node.altitude ?? '-'}</dd></div>
        <div><dt>经度</dt><dd>${node.longitude.toFixed(5)}</dd></div>
        <div><dt>纬度</dt><dd>${node.latitude.toFixed(5)}</dd></div>
        <div><dt>位置精度</dt><dd>${report?.position_precision ?? '-'}</dd></div>
        <div><dt>在线节点</dt><dd>${report?.num_online_local_nodes ?? '-'}</dd></div>
      </dl>
    </div>
  `
}

function buildClusterPopupHTML(cluster: MapClusterNode): string {
  return `
    <div class="node-popup">
      <strong>聚合坐标</strong>
      <dl>
        <div><dt>数量</dt><dd>${cluster.count}</dd></div>
        <div><dt>经度</dt><dd>${cluster.longitude.toFixed(5)}</dd></div>
        <div><dt>纬度</dt><dd>${cluster.latitude.toFixed(5)}</dd></div>
      </dl>
    </div>
  `
}

function clusterIconSize(count: number): number {
  if (count >= 1000) {
    return 58
  }
  if (count >= 100) {
    return 50
  }
  if (count >= 10) {
    return 42
  }
  return 34
}

function clusterClass(count: number): string {
  if (count >= 1000) {
    return 'cluster-large'
  }
  if (count >= 100) {
    return 'cluster-medium'
  }
  return 'cluster-small'
}

function formatCount(count: number): string {
  return count >= 1000 ? `${Math.round(count / 100) / 10}k` : String(count)
}

function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, value))
}

function normalizeLongitude(value: number): number {
  let normalized = value
  while (normalized < -180) {
    normalized += 360
  }
  while (normalized > 180) {
    normalized -= 360
  }
  return normalized
}

function nodeColor(nodeId: string): string {
  let hash = 0
  for (let index = 0; index < nodeId.length; index += 1) {
    hash = (hash * 31 + nodeId.charCodeAt(index)) >>> 0
  }

  const hueRanges = [
    [42, 68],
    [92, 136],
    [188, 218],
    [330, 354],
  ]
  const range = hueRanges[hash % hueRanges.length]
  const hue = range[0] + (hash % (range[1] - range[0]))
  const saturation = 24 + (hash % 14)
  const lightness = 42 + (hash % 12)
  return `hsl(${hue} ${saturation}% ${lightness}%)`
}

function escapeHTML(value: string): string {
  return value.replace(/[&<>'"]/g, (char) => {
    const entities: Record<string, string> = {
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      "'": '&#39;',
      '"': '&quot;',
    }
    return entities[char]
  })
}
</script>

<template>
  <section class="map-panel panel">
    <div ref="mapEl" class="map-container"></div>
    <div
      class="map-source-control"
      @click.stop
      @mousedown.stop
      @dblclick.stop
      @wheel.stop
    >
      <button class="map-source-icon" type="button" aria-label="切换地图图源">
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
          <path stroke="none" d="M0 0h24v24H0z" fill="none" />
          <path d="M12 18.5l-3 -1.5l-6 3v-13l6 -3l6 3l6 -3v7.5" />
          <path d="M9 4v13" />
          <path d="M15 7v5.5" />
          <path d="M21.121 20.121a3 3 0 1 0 -4.242 0c.418 .419 1.125 1.045 2.121 1.879c1.051 -.89 1.759 -1.516 2.121 -1.879" />
          <path d="M19 18v.01" />
        </svg>
      </button>
      <div class="map-source-popover">
        <div class="map-source-drawer-header">
          <span>地图图源</span>
        </div>
        <div v-if="mapSources.length > 1" class="map-source-options">
          <button
            v-for="source in mapSources"
            :key="source.id"
            class="map-source-option"
            :class="{ active: source.id === mapSource.id }"
            type="button"
            @click="selectMapSource(source.id)"
          >
            <span class="map-source-option-name">{{ source.name }}</span>
            <span v-if="source.id === mapSource.id" class="map-source-option-check">当前</span>
          </button>
        </div>
        <span v-else class="map-source-control-pill">{{ mapSource.name }}</span>
      </div>
    </div>
    <!-- <div v-if="loading" class="map-empty">正在加载当前区域坐标...</div>
    <div v-else-if="items.length === 0" class="map-empty">暂无可显示坐标的节点</div> -->
    <div
      v-if="menuNode"
      class="context-menu"
      :style="{ left: `${menuX}px`, top: `${menuY}px` }"
      @click.stop
    >
      <a :href="nodeDetailHref(menuNode.node_id)">节点详细</a>
      <button v-if="isAdmin" class="danger" type="button" @click="deleteSelectedNode">删除</button>
      <button v-if="isAdmin" class="danger" type="button" @click="purgeSelectedNode">删除节点</button>
      <button v-if="isAdmin" class="danger" type="button" @click="deleteAndBlockSelectedNode">删除并屏蔽节点</button>
    </div>
    <div
      v-if="menuMap && isAdmin"
      class="context-menu"
      :style="{ left: `${menuX}px`, top: `${menuY}px` }"
      @click.stop
    >
      <button class="danger" type="button" @click="deleteAllDisplayedNodes">删除所有显示的节点</button>
    </div>
  </section>
</template>
