/**
 * RecordsMap — self-contained Leaflet map component (M2-T09).
 *
 * THIS IS THE ONLY MODULE IN THE APP THAT IMPORTS LEAFLET / REACT-LEAFLET.
 * All data fetching and state lives outside; this component receives typed props.
 */

import { useEffect, useRef, useCallback } from 'react'
import { MapContainer, TileLayer, useMap } from 'react-leaflet'
import * as L from 'leaflet'
import {
  Icon,
  DivIcon,
  marker as leafletMarker,
  heatLayer as leafletHeatLayer,
  type HeatLayer,
} from 'leaflet'

// Leaflet CSS — must be imported once; this component is the single place.
import 'leaflet/dist/leaflet.css'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'

// Side-effect imports (augment L with heatLayer and markerClusterGroup)
import 'leaflet.heat'
import 'leaflet.markercluster'

import { peakGridCount } from './mapUtils'
import type { HeatPoint, LocationMapPoint, PooMapPoint } from './mapUtils'
import type { LocationRecord, PooRecord } from '../records'

// Fix default Leaflet marker icon paths broken by Vite asset handling.
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'
import markerIcon from 'leaflet/dist/images/marker-icon.png'
import markerShadow from 'leaflet/dist/images/marker-shadow.png'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (Icon.Default.prototype as any)._getIconUrl
Icon.Default.mergeOptions({
  iconRetinaUrl: markerIcon2x,
  iconUrl: markerIcon,
  shadowUrl: markerShadow,
})

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------

export interface RecordsMapProps {
  locationHeatPoints: HeatPoint[]
  pooHeatPoints: HeatPoint[]
  locationScatterPoints: LocationMapPoint[]
  pooScatterPoints: PooMapPoint[]

  showLocationHeat: boolean
  showPooHeat: boolean
  showScatter: boolean

  onSelectLocation?: (record: LocationRecord) => void
  onSelectPoo?: (record: PooRecord) => void

  /** Map container height (CSS value). Default: '100%'. */
  height?: string

  /** Use dark base tiles to match the app's dark color scheme. */
  dark?: boolean
}

// OSM (light) and CARTO dark_all (dark) raster tiles — both zero-key.
const LIGHT_TILES = {
  url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
  attribution:
    '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}
const DARK_TILES = {
  url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
  attribution:
    '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
}

// ---------------------------------------------------------------------------
// Inner child: Heat layers (uses useMap hook — must be inside MapContainer)
// ---------------------------------------------------------------------------

interface HeatLayerChildProps {
  locationHeatPoints: HeatPoint[]
  pooHeatPoints: HeatPoint[]
  showLocationHeat: boolean
  showPooHeat: boolean
}

// Heat layer geometry. maxZoom:0 makes leaflet.heat's zoom intensity factor f=1
// at every zoom, so accumulated per-cell intensity equals the raw point count —
// which lets us normalize with a pixel-grid count below.
const LOC_HEAT = { radius: 20, blur: 15 }
const POO_HEAT = { radius: 25, blur: 18 }

/**
 * leaflet.heat `max` (normalization denominator) for the CURRENT viewport:
 * project the points that are visible (within the map size + a radius margin) to
 * container pixels, then count the densest pixel cell using leaflet.heat's own
 * grid (cell = (radius+blur)/2). The densest visible cluster maps to the hot
 * color; recomputing on every zoom/pan keeps it normalized to what's on screen.
 */
function viewportHeatMax(map: L.Map, points: HeatPoint[], radius: number, blur: number): number {
  if (points.length === 0) return 1
  const cell = (radius + blur) / 2
  const size = map.getSize()
  const margin = radius + blur
  const coords: Array<[number, number]> = []
  for (let i = 0; i < points.length; i++) {
    const p = map.latLngToContainerPoint([points[i][0], points[i][1]])
    if (p.x < -margin || p.y < -margin || p.x > size.x + margin || p.y > size.y + margin) continue
    coords.push([p.x, p.y])
  }
  return peakGridCount(coords, cell)
}

export function HeatLayers({
  locationHeatPoints,
  pooHeatPoints,
  showLocationHeat,
  showPooHeat,
}: HeatLayerChildProps) {
  const map = useMap()
  const locationLayerRef = useRef<HeatLayer | null>(null)
  const pooLayerRef = useRef<HeatLayer | null>(null)

  // Latest data/visibility in refs so the once-registered map move/zoom handler
  // re-normalizes against the current points without re-subscribing.
  const locPointsRef = useRef(locationHeatPoints)
  const pooPointsRef = useRef(pooHeatPoints)
  const showLocRef = useRef(showLocationHeat)
  const showPooRef = useRef(showPooHeat)
  useEffect(() => {
    locPointsRef.current = locationHeatPoints
    pooPointsRef.current = pooHeatPoints
    showLocRef.current = showLocationHeat
    showPooRef.current = showPooHeat
  })

  // Location heat layer
  useEffect(() => {
    if (!locationLayerRef.current) {
      locationLayerRef.current = leafletHeatLayer([], {
        ...LOC_HEAT,
        maxZoom: 0,
        gradient: { 0.4: 'blue', 0.65: 'lime', 1: 'red' },
      })
    }
    const layer = locationLayerRef.current
    if (showLocationHeat) {
      // Add the layer to the map BEFORE setLatLngs. A heat layer that is not on
      // a map has a null `_map`, and `setLatLngs -> redraw` dereferences
      // `_map._animating`, which throws and white-screens the SPA.
      if (!map.hasLayer(layer)) map.addLayer(layer)
      layer.setLatLngs(locationHeatPoints)
      layer.setOptions({ max: viewportHeatMax(map, locationHeatPoints, LOC_HEAT.radius, LOC_HEAT.blur) })
    } else {
      if (map.hasLayer(layer)) map.removeLayer(layer)
    }
    return () => {
      if (map.hasLayer(layer)) map.removeLayer(layer)
    }
  }, [map, locationHeatPoints, showLocationHeat])

  // Poo heat layer
  useEffect(() => {
    if (!pooLayerRef.current) {
      pooLayerRef.current = leafletHeatLayer([], {
        ...POO_HEAT,
        maxZoom: 0,
        // High-frequency poo spots reach red (per request); mid tones stay
        // yellow/orange to distinguish from the location layer.
        gradient: { 0.4: 'yellow', 0.7: 'orange', 1: 'red' },
      })
    }
    const layer = pooLayerRef.current
    if (showPooHeat) {
      // Add to the map before setLatLngs (see the location heat layer above).
      if (!map.hasLayer(layer)) map.addLayer(layer)
      layer.setLatLngs(pooHeatPoints)
      layer.setOptions({ max: viewportHeatMax(map, pooHeatPoints, POO_HEAT.radius, POO_HEAT.blur) })
    } else {
      if (map.hasLayer(layer)) map.removeLayer(layer)
    }
    return () => {
      if (map.hasLayer(layer)) map.removeLayer(layer)
    }
  }, [map, pooHeatPoints, showPooHeat])

  // Re-normalize each visible layer to the viewport peak on pan/zoom.
  useEffect(() => {
    const recompute = () => {
      const loc = locationLayerRef.current
      if (loc && showLocRef.current && map.hasLayer(loc)) {
        loc.setOptions({ max: viewportHeatMax(map, locPointsRef.current, LOC_HEAT.radius, LOC_HEAT.blur) })
      }
      const poo = pooLayerRef.current
      if (poo && showPooRef.current && map.hasLayer(poo)) {
        poo.setOptions({ max: viewportHeatMax(map, pooPointsRef.current, POO_HEAT.radius, POO_HEAT.blur) })
      }
    }
    map.on('moveend', recompute)
    map.on('zoomend', recompute)
    return () => {
      map.off('moveend', recompute)
      map.off('zoomend', recompute)
    }
  }, [map])

  return null
}

// ---------------------------------------------------------------------------
// Inner child: Scatter / cluster layer
// ---------------------------------------------------------------------------

interface ScatterLayerChildProps {
  locationScatterPoints: LocationMapPoint[]
  pooScatterPoints: PooMapPoint[]
  showScatter: boolean
  onSelectLocation?: (record: LocationRecord) => void
  onSelectPoo?: (record: PooRecord) => void
}

const locationIcon = new Icon({
  iconUrl: markerIcon,
  iconRetinaUrl: markerIcon2x,
  shadowUrl: markerShadow,
  iconSize: [25, 41],
  iconAnchor: [12, 41],
  popupAnchor: [1, -34],
  shadowSize: [41, 41],
})

const pooIcon = new DivIcon({
  html: '<div style="font-size:20px;line-height:1;">💩</div>',
  className: '',
  iconSize: [24, 24],
  iconAnchor: [12, 12],
})

export function ScatterLayer({
  locationScatterPoints,
  pooScatterPoints,
  showScatter,
  onSelectLocation,
  onSelectPoo,
}: ScatterLayerChildProps) {
  const map = useMap()
  const clusterGroupRef = useRef<L.MarkerClusterGroup | null>(null)

  const rebuild = useCallback(() => {
    if (clusterGroupRef.current) {
      map.removeLayer(clusterGroupRef.current)
      clusterGroupRef.current = null
    }
    if (!showScatter) return

    // markerClusterGroup is augmented onto the imported L namespace by the
    // leaflet.markercluster side-effect import above. Using the imported
    // namespace (not window.L) is what works in Vite ESM bundles.
    const group = L.markerClusterGroup({ maxClusterRadius: 50, showCoverageOnHover: false })

    for (const pt of locationScatterPoints) {
      const m = leafletMarker([pt.lat, pt.lng], { icon: locationIcon })
      m.bindTooltip(`${pt.record.person}<br/>${pt.record.datetime}`, { sticky: true })
      if (onSelectLocation) m.on('click', () => onSelectLocation(pt.record))
      group.addLayer(m)
    }

    for (const pt of pooScatterPoints) {
      const m = leafletMarker([pt.lat, pt.lng], { icon: pooIcon })
      m.bindTooltip(`${pt.record.timestamp}<br/>${pt.record.status}`, { sticky: true })
      if (onSelectPoo) m.on('click', () => onSelectPoo(pt.record))
      group.addLayer(m)
    }

    map.addLayer(group)
    clusterGroupRef.current = group
  }, [map, locationScatterPoints, pooScatterPoints, showScatter, onSelectLocation, onSelectPoo])

  useEffect(() => {
    rebuild()
    return () => {
      if (clusterGroupRef.current) {
        map.removeLayer(clusterGroupRef.current)
        clusterGroupRef.current = null
      }
    }
  }, [rebuild, map])

  return null
}

// ---------------------------------------------------------------------------
// Public component
// ---------------------------------------------------------------------------

/** Default map center: Beijing area. */
const DEFAULT_CENTER: [number, number] = [39.9, 116.4]
const DEFAULT_ZOOM = 11

export function RecordsMap({
  locationHeatPoints,
  pooHeatPoints,
  locationScatterPoints,
  pooScatterPoints,
  showLocationHeat,
  showPooHeat,
  showScatter,
  onSelectLocation,
  onSelectPoo,
  height = '100%',
  dark = false,
}: RecordsMapProps) {
  const tiles = dark ? DARK_TILES : LIGHT_TILES
  return (
    <MapContainer
      center={DEFAULT_CENTER}
      zoom={DEFAULT_ZOOM}
      style={{ height, width: '100%', background: dark ? '#1a1b1e' : undefined }}
      data-testid="records-map"
    >
      {/* key forces a clean tile-layer swap when the color scheme changes */}
      <TileLayer key={tiles.url} attribution={tiles.attribution} url={tiles.url} />

      <HeatLayers
        locationHeatPoints={locationHeatPoints}
        pooHeatPoints={pooHeatPoints}
        showLocationHeat={showLocationHeat}
        showPooHeat={showPooHeat}
      />

      <ScatterLayer
        locationScatterPoints={locationScatterPoints}
        pooScatterPoints={pooScatterPoints}
        showScatter={showScatter}
        onSelectLocation={onSelectLocation}
        onSelectPoo={onSelectPoo}
      />
    </MapContainer>
  )
}
