import { useEffect, useRef, useState } from 'react'
import { cellToBoundary } from 'h3-js'
import 'maplibre-gl/dist/maplibre-gl.css'
import { useTwinStore } from '../lib/twin/store'
import { getBasemap } from '../lib/twin/basemaps'

// Bengaluru center - MapLibre uses [lng, lat] order.
const CENTER: [number, number] = [77.5946, 12.9716]

// Tilted overview camera (the default view, and where deselect returns to).
const OVERVIEW = {
  center: CENTER,
  zoom: 11.5,
  pitch: 62,
  bearing: -20,
}

// Camera used when a hex is selected - fully top-down (vertical), modestly
// zoomed so the hex fills more of the frame while keeping street context.
const SELECTED_ZOOM = 16.4
const SELECTED_PITCH = 0
const SELECTED_BEARING = 0
// Hex-to-hex / select transition duration - slow enough to clearly perceive
// the camera gliding from one zone to the next, even when stepping fast.
const HEX_TRANSITION_MS = 1900

// While a hex is selected the camera is CONSTRAINED (not frozen): you can still
// pan a little and nudge the zoom, but you can't zoom far out or wander off the
// hex. These define how much slack to allow around the selected view.
const SELECTED_MIN_ZOOM = SELECTED_ZOOM - 0.8 // can't zoom out far
const SELECTED_MAX_ZOOM = SELECTED_ZOOM + 2.5 // can zoom in for detail
const SELECTED_PAN_PAD = 0.012 // ~1.3km of pan slack around the hex (degrees)

// Layer-builder module, loaded once and cached (keeps deck.gl out of SSR).
type LayersModule = typeof import('../lib/twin/layers')

// How long each zone stays clear + settled before the camera moves to the next
// queued zone. Guarantees a beat of unblurred visibility per zone even when
// stepping fast through the list.
const DWELL_MS = 650

export function MapView() {
  const containerRef = useRef<HTMLDivElement | null>(null)
  const mapRef = useRef<any>(null)
  const overlayRef = useRef<any>(null)
  const layersModRef = useRef<LayersModule | null>(null)

  // Currently DISPLAYED selection (what the layers + camera reflect).
  const currentRef = useRef<string | null>(null)
  // Pending camera targets - every list step pushes one so fast stepping still
  // plays a continuous fly-through rather than jumping to the last.
  const queueRef = useRef<Array<string>>([])
  const flyingRef = useRef(false)
  // Dwell timer between queued hops (cleared on deselect / direct fly).
  const dwellRef = useRef<number | null>(null)

  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading')
  // Selected hex mirrored into React state to drive the blur/spotlight overlay.
  const [selected, setSelected] = useState<string | null>(null)
  // Screen-pixel corners of the selected hex (the blur hole). Null until the
  // camera settles so the hole only appears once the view is crisp.
  const [hexScreen, setHexScreen] = useState<Array<[number, number]> | null>(null)

  // Compose deck.gl layers for a given selection. roads=true adds the road
  // slices (only after the camera settles).
  const renderLayers = (hexId: string | null, withRoads: boolean) => {
    const mod = layersModRef.current
    const overlay = overlayRef.current
    if (!mod || !overlay) return
    const { buildHexLayer, buildPatrolGlowLayer, buildRoadLayers, buildSelectedHexLayer, segmentsInHex } = mod
    if (!hexId) {
      overlay.setProps({ layers: [buildPatrolGlowLayer(null), buildHexLayer(null)] })
      return
    }
    const layers: Array<unknown> = [
      buildPatrolGlowLayer(hexId),
      buildHexLayer(hexId),
      buildSelectedHexLayer(hexId),
    ]
    if (withRoads) layers.push(...buildRoadLayers(segmentsInHex(hexId)))
    overlay.setProps({ layers })
  }

  // Project a hex's H3 boundary to screen-pixel corners for the blur hole.
  const computeHexScreen = (hexId: string) => {
    const map = mapRef.current
    if (!map) return
    const pts = cellToBoundary(hexId).map(([lat, lng]) => {
      const p = map.project([lng, lat])
      return [p.x, p.y] as [number, number]
    })
    setHexScreen(pts)
  }

  // Drain the camera queue one target at a time. Each hop is a brief flight so
  // a fast run through the list reads as continuous motion. When the backlog
  // grows (fast scrolling), shorten each hop a little so the camera keeps up.
  const drainQueue = () => {
    const map = mapRef.current
    if (!map || flyingRef.current) return
    const next = queueRef.current.shift()
    if (next === undefined) return

    flyingRef.current = true
    currentRef.current = next
    setSelected(next)

    const hex = layersModRef.current?.HEXES?.find?.((h: any) => h.h3 === next)
    renderLayers(next, false)
    // Position the hole on the target NOW (using current camera) so the blur
    // appears with a clear hex immediately; the move handler keeps it aligned
    // as the camera flies in.
    computeHexScreen(next)

    const center = hex ? ([hex.center[1], hex.center[0]] as [number, number]) : undefined

    // Release constraints so the flight to the next hex isn't fenced in.
    unlockCamera()
    map.flyTo({
      center: center ?? map.getCenter(),
      zoom: SELECTED_ZOOM,
      // Stay fully top-down throughout - never inherit/flash the overview tilt
      // when moving between already-selected hexes.
      pitch: SELECTED_PITCH,
      bearing: SELECTED_BEARING,
      duration: HEX_TRANSITION_MS,
      essential: true,
      easing: (t: number) => 1 - Math.pow(1 - t, 3),
    })

    map.once('moveend', () => {
      // The flight for THIS zone has landed - render it clearly (roads + crisp
      // hole) so the user actually sees the unblurred hex...
      if (currentRef.current === next) {
        renderLayers(next, true)
        settleHole(next)
        // Re-fence the camera around the newly-landed hex (only if this is the
        // final queued hop - otherwise the next flight unlocks again anyway).
        if (queueRef.current.length === 0) lockCameraToHex(next)
      }
      // ...then DWELL before moving on, even if more zones are queued. This is
      // what gives each zone a beat of clear, settled visibility instead of
      // snapping straight through to the next.
      if (dwellRef.current) window.clearTimeout(dwellRef.current)
      dwellRef.current = window.setTimeout(() => {
        flyingRef.current = false
        drainQueue()
      }, DWELL_MS)
    })
  }

  // Fly straight to a single target (map-click selection) - clears any backlog.
  const flyDirect = (hexId: string) => {
    const map = mapRef.current
    if (!map) return
    if (dwellRef.current) window.clearTimeout(dwellRef.current)
    queueRef.current = []
    currentRef.current = hexId
    setSelected(hexId)
    renderLayers(hexId, false)
    computeHexScreen(hexId)
    const hex = layersModRef.current?.HEXES?.find?.((h: any) => h.h3 === hexId)
    const center = hex ? ([hex.center[1], hex.center[0]] as [number, number]) : map.getCenter()
    flyingRef.current = true
    unlockCamera()
    map.flyTo({
      center,
      zoom: SELECTED_ZOOM,
      pitch: SELECTED_PITCH,
      bearing: SELECTED_BEARING,
      duration: HEX_TRANSITION_MS,
      essential: true,
    })
    map.once('moveend', () => {
      flyingRef.current = false
      if (currentRef.current === hexId) {
        renderLayers(hexId, true)
        settleHole(hexId)
        lockCameraToHex(hexId)
      }
    })
  }

  // Constrain (not freeze) the camera around the selected hex: clamp the zoom
  // range and fence panning to a padded box around the hex boundary, so the
  // user keeps a little freedom of movement but can't zoom way out or wander
  // off the zone.
  const lockCameraToHex = (hexId: string) => {
    const map = mapRef.current
    if (!map) return
    let minLng = Infinity
    let minLat = Infinity
    let maxLng = -Infinity
    let maxLat = -Infinity
    for (const [lat, lng] of cellToBoundary(hexId)) {
      if (lng < minLng) minLng = lng
      if (lng > maxLng) maxLng = lng
      if (lat < minLat) minLat = lat
      if (lat > maxLat) maxLat = lat
    }
    map.setMaxBounds([
      [minLng - SELECTED_PAN_PAD, minLat - SELECTED_PAN_PAD],
      [maxLng + SELECTED_PAN_PAD, maxLat + SELECTED_PAN_PAD],
    ])
    map.setMinZoom(SELECTED_MIN_ZOOM)
    map.setMaxZoom(SELECTED_MAX_ZOOM)
  }

  // Release all selection constraints so the camera can fly back to the wide
  // overview on deselect.
  const unlockCamera = () => {
    const map = mapRef.current
    if (!map) return
    map.setMaxBounds(null)
    map.setMinZoom(0)
    map.setMaxZoom(22)
  }

  // After the camera stops, wait for crisp high-zoom tiles, then align the hole.
  const settleHole = (hexId: string) => {
    const map = mapRef.current
    if (!map) return
    const ensure = () => {
      if (currentRef.current !== hexId) return
      computeHexScreen(hexId)
      if (!map.areTilesLoaded()) map.once('idle', ensure)
    }
    ensure()
  }

  const deselect = () => {
    if (dwellRef.current) window.clearTimeout(dwellRef.current)
    queueRef.current = []
    currentRef.current = null
    flyingRef.current = false
    setSelected(null)
    setHexScreen(null)
    unlockCamera()
    renderLayers(null, false)
    // Explicitly restore the tilted overview angle - MapLibre keeps the last
    // pitch/bearing (top-down) unless we set them back here, which is what
    // otherwise leaves the camera stuck flat after a deselect.
    mapRef.current?.flyTo({
      center: OVERVIEW.center,
      zoom: OVERVIEW.zoom,
      pitch: OVERVIEW.pitch,
      bearing: OVERVIEW.bearing,
      duration: 1400,
      essential: true,
    })
  }

  // Subscribe to the store imperatively so EVERY selection transition is
  // captured (React render coalescing would drop intermediate steps during
  // fast keyboard stepping).
  useEffect(() => {
    let prev = useTwinStore.getState().selectedHexId
    const unsub = useTwinStore.subscribe((state) => {
      const id = state.selectedHexId
      if (id === prev) return
      prev = id
      if (id === null) {
        deselect()
      } else {
        // Both list and map selections fly STRAIGHT to the latest pick,
        // interrupting any in-flight hop. Fast stepping through the list now
        // lands on the final selection instead of visiting every intermediate
        // zone in turn.
        flyDirect(id)
      }
    })
    return unsub
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  // React to basemap changes (style switcher / theme toggle): swap the style,
  // then re-install our decorations + overlay once the new style has parsed.
  useEffect(() => {
    let prevBasemap = useTwinStore.getState().basemapId
    const unsub = useTwinStore.subscribe((state) => {
      if (state.basemapId === prevBasemap) return
      prevBasemap = state.basemapId
      const map = mapRef.current
      if (!map) return
      map.setStyle(getBasemap(state.basemapId).style as any)
      map.once('styledata', () => {
        declutterLabels(map)
        addBoundaryLine(map)
        applyMapLayers(map, useTwinStore.getState().layers)
        // Re-render the current overlay (deck layers are dropped on setStyle).
        renderLayers(currentRef.current, !!currentRef.current)
      })
    })
    return unsub
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  // React to layer-visibility toggles (Map panel) - apply to the live style.
  useEffect(() => {
    let prevLayers = useTwinStore.getState().layers
    const unsub = useTwinStore.subscribe((state) => {
      if (state.layers === prevLayers) return
      prevLayers = state.layers
      const map = mapRef.current
      if (map) applyMapLayers(map, state.layers)
    })
    return unsub
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])

  useEffect(() => {
    let cancelled = false
    // Re-gate the panels on (re)mount until the fly-in completes.
    useTwinStore.getState().setFlyInDone(false)

    import('maplibre-gl').then(({ Map, NavigationControl }) => {
      if (cancelled || !containerRef.current) return

      const map = new Map({
        container: containerRef.current,
        style: getBasemap(useTwinStore.getState().basemapId).style as any,
        center: CENTER,
        zoom: 5,
        pitch: 0,
        bearing: 0,
        attributionControl: { compact: true },
        pixelRatio: window.devicePixelRatio || 1,
      })

      mapRef.current = map
      map.addControl(new NavigationControl({ visualizePitch: true }), 'top-right')

      map.on('load', () => {
        if (cancelled) return
        setStatus('ready')
        declutterLabels(map)
        addBoundaryLine(map)
        applyMapLayers(map, useTwinStore.getState().layers)
        addTwinOverlay(map, overlayRef, layersModRef)

        // Click routing: pick a hex → select via store (map source); empty
        // space → deselect.
        map.on('click', (e: any) => {
          const overlay = overlayRef.current
          const picked = overlay?.pickObject?.({
            x: e.point.x,
            y: e.point.y,
            layerIds: ['twin-hexagons', 'twin-hex-selected'],
          })
          const store = useTwinStore.getState()
          if (picked?.object?.h3 && picked?.object?.type !== 'calm') {
            if (picked.object.h3 !== store.selectedHexId) {
              store.selectFromMap(picked.object.h3)
            }
          } else {
            store.selectFromMap(null)
          }
        })

        // Keep the blur hole aligned with the selected hex at all times -
        // including DURING the flight - so the background is blurred from the
        // start and the hex is already clear well before the camera settles.
        map.on('move', () => {
          if (currentRef.current) computeHexScreen(currentRef.current)
        })

        // Cinematic swoop from the high aerial view down into the overview.
        // Signal completion ONCE so the dashboard panels animate in only after
        // the fly-in fully settles.
        map.flyTo({
          ...OVERVIEW,
          duration: 3500,
          essential: true,
          easing: (t: number) => 1 - Math.pow(1 - t, 3),
        })
        map.once('moveend', () => {
          useTwinStore.getState().setFlyInDone(true)
        })
      })

      map.on('error', (e: any) => {
        console.error('[MapLibre]', e?.error ?? e)
        if (!cancelled) setStatus('error')
      })
    })

    // Esc deselects (clears list selection + camera + blur).
    const onKey = (ev: KeyboardEvent) => {
      if (ev.key === 'Escape') useTwinStore.getState().selectFromMap(null)
    }
    window.addEventListener('keydown', onKey)

    return () => {
      cancelled = true
      window.removeEventListener('keydown', onKey)
      if (dwellRef.current) window.clearTimeout(dwellRef.current)
      try {
        overlayRef.current?.finalize?.()
        mapRef.current?.remove()
      } catch {
        // ignore teardown errors during hot reload
      }
      overlayRef.current = null
      mapRef.current = null
    }
  }, [])

  return (
    <div className="fixed inset-0 bg-bg">
      <div ref={containerRef} className="absolute inset-0 h-full w-full" />

      {/* Blur + dim everywhere EXCEPT the selected hex, punched out as a clear
          hole so the real map shows through crisply. Click-through. */}
      <BlurWithHole active={!!selected} hexScreen={hexScreen} />

      {status === 'loading' && (
        <div className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 font-mono text-sm" style={{ color: 'rgba(133,149,170,0.85)' }}>
          Loading map…
        </div>
      )}

      {status === 'error' && (
        <div className="absolute left-1/2 top-1/2 max-w-sm -translate-x-1/2 -translate-y-1/2 px-4 py-3 text-center font-mono text-sm" style={{ color: '#aab6c8' }}>
          Map failed to load. Check the network connection to CARTO basemaps.
        </div>
      )}
    </div>
  )
}

/**
 * Full-screen dark blur overlay with the selected hex punched out as a clear
 * hole, using a donut CSS clip-path so the browser never paints/blurs pixels
 * inside the hex region. Sits behind the floating panels (z-10) and is
 * click-through.
 */
function BlurWithHole({
  active,
  hexScreen,
}: {
  active: boolean
  hexScreen: Array<[number, number]> | null
}) {
  const [size, setSize] = useState<[number, number]>([0, 0])

  useEffect(() => {
    const update = () => setSize([window.innerWidth, window.innerHeight])
    update()
    window.addEventListener('resize', update)
    return () => window.removeEventListener('resize', update)
  }, [])

  const [w, h] = size
  const hasHole = !!(hexScreen && hexScreen.length >= 3)

  let clipPath: string | undefined
  if (w && h) {
    const rect = `0px 0px, ${w}px 0px, ${w}px ${h}px, 0px ${h}px, 0px 0px`
    if (hasHole) {
      const hex = hexScreen!.map(([x, y]) => `${x}px ${y}px`).join(', ')
      const first = `${hexScreen![0][0]}px ${hexScreen![0][1]}px`
      clipPath = `polygon(evenodd, ${rect}, ${hex}, ${first})`
    } else {
      clipPath = `polygon(${rect})`
    }
  }

  return (
    <>
      <div
        className="pointer-events-none absolute inset-0"
        style={{
          zIndex: 10,
          opacity: active ? 1 : 0,
          transition: 'opacity 600ms ease',
          background: 'rgba(0,0,0,0.72)',
          backdropFilter: 'blur(8px)',
          WebkitBackdropFilter: 'blur(8px)',
          clipPath,
          WebkitClipPath: clipPath,
        }}
      />
      {/* Warm-white rim around the clear hole - spotlight frame. */}
      {active && hasHole && (
        <svg className="pointer-events-none absolute inset-0 h-full w-full" style={{ zIndex: 11 }}>
          <polygon
            points={hexScreen!.map(([x, y]) => `${x},${y}`).join(' ')}
            fill="none"
            stroke="#F5EFE6"
            strokeOpacity={0.7}
            strokeWidth={2}
          />
        </svg>
      )}
    </>
  )
}

// ── Basemap layer visibility ─────────────────────────────────────────────
// Map each style layer to a toggleable category by id pattern (checked in
// priority order so e.g. "roadname" counts as a label, not a road).
type LayerCat = 'label' | 'border' | 'water' | 'building' | 'road' | 'land'

function categoryForLayer(id: string): LayerCat | null {
  if (/label|place_|poi|roadname|watername|housenumber|country|state|continent|marine|airport/i.test(id)) return 'label'
  if (/boundary|admin|border/i.test(id)) return 'border'
  if (/water|ocean|sea|river|lake/i.test(id)) return 'water'
  if (/building/i.test(id)) return 'building'
  if (/road|bridge|tunnel|highway|street|transit|rail/i.test(id)) return 'road'
  if (/land|park|wood|forest|grass|sand|wetland|landuse|landcover|pier|aeroway/i.test(id)) return 'land'
  return null
}

/** Apply 2D layer visibility + the extruded 3D building/road overlays. */
function applyMapLayers(map: any, vis: Record<string, boolean>) {
  try {
    const layers = map.getStyle()?.layers ?? []
    for (const layer of layers) {
      if (layer.id.startsWith('twin-') || layer.id.startsWith('blr-')) continue
      const cat = categoryForLayer(layer.id)
      if (!cat) continue
      map.setLayoutProperty(layer.id, 'visibility', vis[cat] ? 'visible' : 'none')
    }
  } catch (err) {
    console.warn('[MapView] could not apply layer visibility', err)
  }
  apply3DLayer(map, 'building', !!vis.building3d)
  apply3DLayer(map, 'road', !!vis.road3d)
}

/**
 * Add or remove an extruded overlay derived from the basemap's vector source.
 * Buildings extrude by render_height; roads get a thin uniform extrusion so the
 * arterial network reads as a raised 3D ribbon.
 */
function apply3DLayer(map: any, kind: 'building' | 'road', on: boolean) {
  const layerId = `twin-3d-${kind}`
  try {
    const existing = map.getLayer(layerId)
    if (!on) {
      if (existing) map.removeLayer(layerId)
      return
    }
    if (existing) return
    // Find a source style layer of this kind to borrow source + source-layer.
    const src = (map.getStyle()?.layers ?? []).find((l: any) => {
      if (kind === 'building') return /building/i.test(l.id) && l['source-layer']
      return /road|transportation/i.test(l.id) && l['source-layer']
    })
    if (!src) return
    map.addLayer({
      id: layerId,
      type: 'fill-extrusion',
      source: src.source,
      'source-layer': src['source-layer'],
      ...(kind === 'building' ? {} : { filter: ['==', ['geometry-type'], 'Polygon'] }),
      paint: {
        'fill-extrusion-color': kind === 'building' ? '#8a93a6' : '#c2ccd9',
        'fill-extrusion-opacity': 0.85,
        'fill-extrusion-height':
          kind === 'building'
            ? ['*', ['coalesce', ['get', 'render_height'], 10], 2.2]
            : 6,
        'fill-extrusion-base':
          kind === 'building' ? ['coalesce', ['get', 'render_min_height'], 0] : 0,
      },
    })
  } catch (err) {
    console.warn(`[MapView] could not toggle 3D ${kind}`, err)
  }
}

function addBoundaryLine(map: any) {
  try {
    if (map.getSource('blr-outer')) return
    map.addSource('blr-outer', {
      type: 'geojson',
      data: '/bangalore-outer-boundary.geojson',
    })
    map.addLayer({
      id: 'blr-outer-line',
      type: 'line',
      source: 'blr-outer',
      layout: { 'line-join': 'round', 'line-cap': 'round' },
      paint: {
        'line-color': '#8595aa',
        'line-width': 2,
        'line-opacity': 0.55,
        // Very short dash + round cap renders as round dots, not dashes.
        'line-dasharray': [0.3, 1.8],
      },
    })
  } catch (err) {
    console.warn('[MapView] could not add outer boundary line', err)
  }
}

/**
 * Wire deck.gl onto the MapLibre map via MapboxOverlay (interleaved). Caches
 * the layers module so the selection handlers can build layers synchronously.
 */
function addTwinOverlay(
  map: any,
  overlayRef: { current: any },
  layersModRef: { current: LayersModule | null },
) {
  Promise.all([import('@deck.gl/mapbox'), import('../lib/twin/layers')])
    .then(([{ MapboxOverlay }, layersMod]) => {
      layersModRef.current = layersMod
      const overlay = new MapboxOverlay({
        interleaved: true,
        layers: [layersMod.buildPatrolGlowLayer(null), layersMod.buildHexLayer()],
      })
      overlayRef.current = overlay
      map.addControl(overlay)
    })
    .catch((err) => {
      console.warn('[MapView] could not add deck.gl twin overlay', err)
    })
}

/**
 * Reduce label clutter on the CARTO dark-matter style by raising the minzoom of
 * small-place / minor-road label layers. State + major-city labels untouched.
 */
function declutterLabels(map: any) {
  try {
    const layers = map.getStyle()?.layers ?? []
    const symbolLayers = layers.filter((l: any) => l.type === 'symbol')

    const minZoomById: Record<string, number> = {
      place_hamlet: 13,
      place_villages: 13,
      place_suburbs: 10,
      place_town: 9,
      roadname_minor: 13,
      roadname_sec: 12,
      housenumber: 16,
    }

    for (const layer of symbolLayers) {
      const target = minZoomById[layer.id]
      if (target === undefined) continue
      const currentMax = typeof layer.maxzoom === 'number' ? layer.maxzoom : 24
      map.setLayerZoomRange(layer.id, target, currentMax)
    }
  } catch (err) {
    console.warn('[MapView] could not declutter labels', err)
  }
}
