import type { GeoPoint, MapViewport } from '@/domain/poi'
import { isTrustworthyCoordinate } from '@/domain/poi'

/**
 * 约 1e-4 度 ≈ 11 米。原生地图把中心点量化到像素，回传的中心点和我们写进去的
 * 值总会差一点点；这个阈值用来吸收那点量化噪声，同时远小于任何一次真实拖动。
 */
const COORDINATE_EPSILON = 1e-4
const SCALE_EPSILON = 0.01

/**
 * 判断地图回传的视口是否真的变了，值得写回 store。
 *
 * 地图的 longitude/latitude/scale 是绑定到 store 的，而 `regionchange` 又会把
 * 地图当前中心点回写 store —— 这是一个闭环。选中标点后我们把视口设到 POI 坐标，
 * 地图动画结束回传一个「差一点点」的中心点，无条件写回就会再次改动绑定值、
 * 触发地图移动、再回传……表现出来就是地图在左右或上下反复抽动。
 *
 * 回写本身是需要的（用户拖完地图，store 要记住新位置，返回页面时才能恢复），
 * 所以这里不是禁止回写，而是把小于阈值的变化当作噪声丢掉，让闭环收敛。
 */
export function isSignificantViewportChange(current: MapViewport, next: MapViewport): boolean {
  return Math.abs(next.longitude - current.longitude) >= COORDINATE_EPSILON
    || Math.abs(next.latitude - current.latitude) >= COORDINATE_EPSILON
    || Math.abs(next.scale - current.scale) >= SCALE_EPSILON
}

/** 微信 `<map>` 的 scale 取值范围是 3~20。 */
const MIN_MAP_SCALE = 3
const MAX_MAP_SCALE = 20

/** Web 墨卡托瓦片边长：z 级时全球 360° 宽 256 × 2^z 像素。 */
const TILE_SIZE = 256

/** 聚类外接矩形与视野边缘之间的余量，避免最外侧点位贴边或被顶部筛选栏压住。 */
const FIT_PADDING_RATIO = 1.25

/** 只有一个点位（或所有点位重合）时没有跨度可依据，直接给街区级视野。 */
const SINGLE_POINT_SCALE = 15

/** 拿不到屏幕尺寸时按最窄的在售机型（iPhone SE）估算，宁可保守一点。 */
const FALLBACK_VIEWPORT_SIZE = { width: 375, height: 560 }

/**
 * 地图之外还有导航栏、顶部筛选栏和底部 tabBar，扣掉它们才是 `<map>` 的实际高度。
 * 地图是 `flex: 1`，拿不到精确值也不影响 —— FIT_PADDING_RATIO 已经留了余量。
 */
const MAP_CHROME_HEIGHT_PX = 180

export interface ClusterViewportOptions {
  /** 地图可视区域宽度（px）。默认读设备屏幕宽度。 */
  width?: number
  /** 地图可视区域高度（px）。默认读设备屏幕高度并扣掉导航栏与 tabBar。 */
  height?: number
}

/**
 * 读设备可视区域尺寸。测试环境里没有 `uni`，回落到 iPhone SE 的估算值。
 * 同 marker.ts 的 rpxToPx，这里也用 typeof 守卫而不是假设宿主一定存在。
 */
function readViewportSize(): { width: number, height: number } {
  if (typeof uni === 'undefined' || typeof uni.getWindowInfo !== 'function')
    return FALLBACK_VIEWPORT_SIZE

  try {
    const info = uni.getWindowInfo()
    const width = Number(info?.windowWidth)
    const height = Number(info?.windowHeight)
    if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
      return FALLBACK_VIEWPORT_SIZE

    return { width, height: Math.max(height - MAP_CHROME_HEIGHT_PX, height / 2) }
  }
  catch {
    return FALLBACK_VIEWPORT_SIZE
  }
}

/**
 * 把所有点位当成一个聚类，算出「一眼看全」的初始视野。
 *
 * 中心点取算术中心（centroid）而不是外接矩形中心：前者跟着点位密度走，点多的那片
 * 会更靠画面中间；后者只由最外侧四个点决定，个别远点就能把中心拽偏。当前数据集里
 * 两者只差 0.36 km，但数据集是会增删的，跟着密度走更稳。
 *
 * scale 由聚类跨度反推而不是写死：点位范围一变，写死的值要么留一圈空白、
 * 要么把最外侧的点切在屏幕外。
 */
export function computeClusterViewport(
  points: readonly GeoPoint[],
  options: ClusterViewportOptions = {},
): MapViewport | null {
  const usable = points.filter(point => isTrustworthyCoordinate(point.longitude, point.latitude))
  if (usable.length === 0)
    return null

  let longitudeSum = 0
  let latitudeSum = 0
  let minLongitude = Number.POSITIVE_INFINITY
  let maxLongitude = Number.NEGATIVE_INFINITY
  let minLatitude = Number.POSITIVE_INFINITY
  let maxLatitude = Number.NEGATIVE_INFINITY

  for (const { longitude, latitude } of usable) {
    longitudeSum += longitude
    latitudeSum += latitude
    minLongitude = Math.min(minLongitude, longitude)
    maxLongitude = Math.max(maxLongitude, longitude)
    minLatitude = Math.min(minLatitude, latitude)
    maxLatitude = Math.max(maxLatitude, latitude)
  }

  const longitude = longitudeSum / usable.length
  const latitude = latitudeSum / usable.length

  return {
    longitude,
    latitude,
    scale: fitScale(maxLongitude - minLongitude, maxLatitude - minLatitude, latitude, options),
  }
}

function fitScale(
  spanLongitude: number,
  spanLatitude: number,
  centerLatitude: number,
  options: ClusterViewportOptions,
): number {
  const measured = readViewportSize()
  const width = options.width ?? measured.width
  const height = options.height ?? measured.height

  if (spanLongitude <= 0 && spanLatitude <= 0)
    return SINGLE_POINT_SCALE

  const candidates: number[] = []
  if (spanLongitude > 0)
    candidates.push(Math.log2(width * 360 / (TILE_SIZE * spanLongitude * FIT_PADDING_RATIO)))
  if (spanLatitude > 0) {
    // 墨卡托投影下，同样度数的纬度跨度比经度跨度占更多像素，按中心纬度折算回等效经度。
    const projected = spanLatitude / Math.cos(centerLatitude * Math.PI / 180)
    candidates.push(Math.log2(height * 360 / (TILE_SIZE * projected * FIT_PADDING_RATIO)))
  }

  // 取两个方向里更小的，保证短边也装得下；向下取到半级，微信 scale 是连续值，
  // 但半级以下的差别肉眼分辨不出来，取整能让不同机型落在同一档、便于断言。
  const fitted = Math.floor(Math.min(...candidates) * 2) / 2
  return Math.min(MAX_MAP_SCALE, Math.max(MIN_MAP_SCALE, fitted))
}
