import { useEffect, useRef, useMemo, useState, createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import mapboxgl from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
import { useSettingsStore } from '../../store/settingsStore'
import { useAuthStore } from '../../store/authStore'
import { getCached, isLoading, fetchPhoto, onThumbReady, getAllThumbs } from '../../services/photoService'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from './mapboxSetup'
import { attachLocationMarker, type LocationMarkerHandle } from './locationMarkerMapbox'
import { ReservationMapboxOverlay } from './reservationsMapbox'
import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types'

function categoryIconSvg(iconName: string | null | undefined, size: number): string {
  const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
  try {
    return renderToStaticMarkup(createElement(IconComponent, { size, color: 'white', strokeWidth: 2.5 }))
  } catch { return '' }
}

interface RouteSegment {
  mid: [number, number]
  from: [number, number]
  to: [number, number]
  walkingText?: string
  drivingText?: string
}

interface Props {
  places: Place[]
  dayPlaces?: Place[]
  route?: [number, number][][] | null
  routeSegments?: RouteSegment[]
  selectedPlaceId?: number | null
  onMarkerClick?: (id: number) => void
  onMapClick?: (info: { latlng: { lat: number; lng: number } }) => void
  onMapContextMenu?: ((e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => void) | null
  center?: [number, number]
  zoom?: number
  fitKey?: number | null
  dayOrderMap?: Record<number, number[] | null>
  leftWidth?: number
  rightWidth?: number
  hasInspector?: boolean
  hasDayDetail?: boolean
  reservations?: Reservation[]
  visibleConnectionIds?: number[]
  showReservationStats?: boolean
  onReservationClick?: (reservationId: number) => void
}

function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
  const size = selected ? 44 : 36
  const borderColor = selected ? '#111827' : (place.category_color || 'white')
  const borderWidth = selected ? 3 : 2.5
  const shadow = selected
    ? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
    : '0 2px 8px rgba(0,0,0,0.22)'
  const bgColor = place.category_color || '#6b7280'

  // The visual circle is `size` + 2*border on each side. To make the
  // mapbox `anchor: 'center'` land on the real visual middle of the marker
  // (rather than just the inner content box), the wrapper has to be the
  // full outer size. If we gave the wrapper only `size`, the border would
  // bleed outside it and the route lines would appear slightly off.
  const outer = size + borderWidth * 2

  let badgeHtml = ''
  if (orderNumbers && orderNumbers.length > 0) {
    const label = orderNumbers.join(' · ')
    badgeHtml = `<span style="
      position:absolute;bottom:-2px;right:-2px;
      min-width:18px;height:${orderNumbers.length > 1 ? 16 : 18}px;border-radius:${orderNumbers.length > 1 ? 8 : 9}px;
      padding:0 ${orderNumbers.length > 1 ? 4 : 3}px;
      background:rgba(255,255,255,0.94);
      border:1.5px solid rgba(0,0,0,0.15);
      box-shadow:0 1px 4px rgba(0,0,0,0.18);
      display:flex;align-items:center;justify-content:center;
      font-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
      font-family:-apple-system,system-ui,sans-serif;line-height:1;
      box-sizing:border-box;white-space:nowrap;
    ">${label}</span>`
  }

  const wrap = document.createElement('div')
  // Do NOT set `position: relative` here — mapbox-gl ships
  // `.mapboxgl-marker { position: absolute }` and relies on it. An inline
  // `position: relative` here overrides the class, turns every marker into
  // a static block element, and stacks them in document order inside the
  // canvas container. The result looks exactly like "markers drift as the
  // map zooms" because each marker's transform is then applied relative
  // to its stacked slot, not to the map viewport.
  wrap.style.cssText = `width:${outer}px;height:${outer}px;cursor:pointer;`

  const hasPhoto = photoUrl && (photoUrl.startsWith('data:') || photoUrl.startsWith('/api/maps/place-photo/'))
  if (hasPhoto) {
    wrap.innerHTML = `
      <div style="
        position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
        width:${size}px;height:${size}px;border-radius:50%;
        border:${borderWidth}px solid ${borderColor};
        box-shadow:${shadow};
        overflow:hidden;background:${bgColor};
        box-sizing:content-box;
      ">
        <img src="${photoUrl}" width="${size}" height="${size}" style="display:block;border-radius:50%;object-fit:cover;" />
      </div>
      ${badgeHtml}
    `
  } else {
    wrap.innerHTML = `
      <div style="
        position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
        width:${size}px;height:${size}px;border-radius:50%;
        border:${borderWidth}px solid ${borderColor};
        box-shadow:${shadow};
        background:${bgColor};
        display:flex;align-items:center;justify-content:center;
        box-sizing:content-box;
      ">
        ${categoryIconSvg(place.category_icon, selected ? 18 : 15)}
      </div>
      ${badgeHtml}
    `
  }
  return wrap
}

export function MapViewGL({
  places = [],
  dayPlaces = [],
  route = null,
  routeSegments = [],
  selectedPlaceId = null,
  onMarkerClick,
  onMapClick,
  onMapContextMenu = null,
  center = [48.8566, 2.3522],
  zoom = 10,
  fitKey = 0,
  dayOrderMap = {},
  leftWidth = 0,
  rightWidth = 0,
  hasInspector = false,
  hasDayDetail = false,
  reservations = [],
  visibleConnectionIds = [],
  showReservationStats = false,
  onReservationClick,
}: Props) {
  const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
  const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
  const mapbox3d = useSettingsStore(s => s.settings.mapbox_3d_enabled !== false)
  const mapboxQuality = useSettingsStore(s => s.settings.mapbox_quality_mode === true)
  const showEndpointLabels = useSettingsStore(s => s.settings.map_booking_labels) !== false
  const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
  const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
  const [mapReady, setMapReady] = useState(false)
  const containerRef = useRef<HTMLDivElement>(null)
  const mapRef = useRef<mapboxgl.Map | null>(null)
  const markersRef = useRef<Map<number, mapboxgl.Marker>>(new Map())
  const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
  const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
  // Refs so the reservation overlay always sees the latest callback /
  // options without forcing a full overlay rebuild on every prop change.
  const onReservationClickRef = useRef(onReservationClick)
  onReservationClickRef.current = onReservationClick
  const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
  const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
  onClickRefs.current.marker = onMarkerClick
  onClickRefs.current.map = onMapClick
  onClickRefs.current.context = onMapContextMenu

  // Build/rebuild the map on style/token/3d change
  useEffect(() => {
    if (!containerRef.current || !mapboxToken) return
    mapboxgl.accessToken = mapboxToken

    const map = new mapboxgl.Map({
      container: containerRef.current,
      style: mapboxStyle,
      center: [center[1], center[0]],
      zoom,
      pitch: mapbox3d ? 45 : 0,
      attributionControl: true,
      antialias: mapboxQuality,
      projection: mapboxQuality ? 'globe' : 'mercator',
    })
    mapRef.current = map
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    ;(window as any).__trek_map = map

    map.on('load', () => {
      if (mapbox3d) {
        // Terrain is only valuable on satellite styles — on clean vector
        // styles it makes route lines drift off the HTML markers because
        // the lines snap to DEM height while markers stay at sea level.
        if (!isStandardFamily(mapboxStyle) && wantsTerrain(mapboxStyle)) addTerrainAndSky(map)
        if (supportsCustom3d(mapboxStyle)) {
          const dark = document.documentElement.classList.contains('dark')
          addCustom3dBuildings(map, dark)
        }
      }

      // Mapbox Standard ships its own DEM-based terrain that kicks in
      // below zoom 13.7. HTML markers project at sea level, so when the
      // terrain exaggeration ramps up at lower zooms the markers drift
      // away from the 3D buildings and route lines they belong to. The
      // non-satellite Standard style still looks great without terrain,
      // so flatten it out to keep markers pinned. (Satellite variants
      // are left alone — the DEM is what gives them their character.)
      if (mapboxStyle === 'mapbox://styles/mapbox/standard') {
        try { map.setTerrain(null) } catch { /* noop */ }
      }
      // initial route source — kept around so updates can setData() cheaply
      if (!map.getSource('trip-route')) {
        map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
        // Apple-Maps style: a darker-blue casing under a bright-blue core, both
        // rounded. Casing is added first so it sits beneath the core line.
        map.addLayer({
          id: 'trip-route-casing',
          type: 'line',
          source: 'trip-route',
          paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
          layout: { 'line-cap': 'round', 'line-join': 'round' },
        })
        map.addLayer({
          id: 'trip-route-line',
          type: 'line',
          source: 'trip-route',
          paint: { 'line-color': '#0a84ff', 'line-width': 5 },
          layout: { 'line-cap': 'round', 'line-join': 'round' },
        })
      }
      // gpx geometries source (place.route_geometry)
      if (!map.getSource('trip-gpx')) {
        map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
        map.addLayer({
          id: 'trip-gpx-line',
          type: 'line',
          source: 'trip-gpx',
          paint: {
            'line-color': ['coalesce', ['get', 'color'], '#3b82f6'],
            'line-width': 3.5,
            'line-opacity': 0.75,
          },
          layout: { 'line-cap': 'round', 'line-join': 'round' },
        })
      }
      // Signal that sources/layers are attached so overlay effects can
      // safely add their own sources. Style rebuilds reset this via the
      // cleanup below.
      setMapReady(true)
    })

    map.on('click', (e) => {
      const t = e.originalEvent.target as HTMLElement
      if (t.closest('.mapboxgl-marker')) return // markers handle their own click
      onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
    })
    // In the mapbox-gl map the right mouse button is reserved for the
    // built-in rotate/pitch gesture, so we bind the "add place" action
    // to the middle mouse button (button === 1) instead.
    const canvas = map.getCanvasContainer()
    const onAuxDown = (ev: MouseEvent) => {
      if (ev.button !== 1) return
      ev.preventDefault()
      const rect = canvas.getBoundingClientRect()
      const lngLat = map.unproject([ev.clientX - rect.left, ev.clientY - rect.top])
      onClickRefs.current.context?.({
        latlng: { lat: lngLat.lat, lng: lngLat.lng },
        originalEvent: ev,
      })
    }
    // Also suppress the browser's native auxclick menu on middle-click.
    const onAuxClick = (ev: MouseEvent) => {
      if (ev.button === 1) ev.preventDefault()
    }
    canvas.addEventListener('mousedown', onAuxDown)
    canvas.addEventListener('auxclick', onAuxClick)

    // Drop follow mode if the user pans the map manually — matches the
    // Apple Maps behaviour where the blue dot stays but the map no longer
    // chases it until the user taps the button again.
    map.on('dragstart', () => {
      setTrackingMode(prev => prev === 'follow' ? 'show' : prev)
    })

    // Keep HTML markers glued to the terrain / 3D ground. Mapbox projects
    // HTML markers at altitude=0 (sea level) by default, so as soon as the
    // style has a terrain DEM (Standard, Standard Satellite, custom terrain)
    // the markers drift off the places when the camera pitches or zooms —
    // the buildings rise from DEM height, the marker stays at sea level,
    // and the pixel offset grows as the perspective changes.
    //
    // Pushing `[lng, lat, elevation]` through setLngLat tells mapbox to
    // project the marker onto the same ground the route line sits on.
    // We re-apply this every render because DEM tiles stream in async.
    let lastAltUpdate = 0
    const syncMarkerAltitudes = () => {
      const now = performance.now()
      if (now - lastAltUpdate < 80) return // ~12Hz is plenty
      lastAltUpdate = now
      markersRef.current.forEach(marker => {
        const ll = marker.getLngLat()
        let alt = 0
        try {
          const e = map.queryTerrainElevation([ll.lng, ll.lat])
          if (typeof e === 'number' && Number.isFinite(e)) alt = e
        } catch { /* terrain not ready */ }
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const curAlt = (ll as any).alt ?? 0
        if (Math.abs(curAlt - alt) > 0.25) {
          marker.setLngLat([ll.lng, ll.lat, alt])
        }
      })
    }
    map.on('render', syncMarkerAltitudes)

    return () => {
      canvas.removeEventListener('mousedown', onAuxDown)
      canvas.removeEventListener('auxclick', onAuxClick)
      markersRef.current.forEach(m => m.remove())
      markersRef.current.clear()
      if (reservationOverlayRef.current) {
        reservationOverlayRef.current.destroy()
        reservationOverlayRef.current = null
      }
      if (locationMarkerRef.current) {
        locationMarkerRef.current.destroy()
        locationMarkerRef.current = null
      }
      try { map.remove() } catch { /* noop */ }
      mapRef.current = null
      setMapReady(false)
    }
  }, [mapboxStyle, mapboxToken, mapbox3d]) // rebuild on style changes only

  // Photo loading — mirrors the Leaflet MapView. Updates via RAF to batch
  // simultaneous thumb arrivals into one re-render.
  const pendingThumbsRef = useRef<Record<string, string>>({})
  const thumbRafRef = useRef<number | null>(null)
  const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
  useEffect(() => {
    if (!places || places.length === 0 || !placesPhotosEnabled) return
    const cleanups: (() => void)[] = []

    const setThumb = (cacheKey: string, thumb: string) => {
      pendingThumbsRef.current[cacheKey] = thumb
      if (thumbRafRef.current !== null) return
      thumbRafRef.current = requestAnimationFrame(() => {
        thumbRafRef.current = null
        const pending = pendingThumbsRef.current
        pendingThumbsRef.current = {}
        setPhotoUrls(prev => {
          const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v)
          return hasChange ? { ...prev, ...pending } : prev
        })
      })
    }

    for (const place of places) {
      const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
      if (!cacheKey) continue
      const cached = getCached(cacheKey)
      if (cached?.thumbDataUrl) {
        setThumb(cacheKey, cached.thumbDataUrl)
        continue
      }
      cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
      if (!cached && !isLoading(cacheKey)) {
        const photoId =
          (place.image_url?.startsWith('/api/maps/place-photo/') ? place.image_url : null)
          || place.google_place_id
          || place.osm_id
          || place.image_url
        if (photoId || (place.lat && place.lng)) {
          fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
        }
      }
    }

    return () => {
      cleanups.forEach(fn => fn())
      if (thumbRafRef.current !== null) {
        cancelAnimationFrame(thumbRafRef.current)
        thumbRafRef.current = null
      }
    }
  }, [placeIds, placesPhotosEnabled]) // eslint-disable-line react-hooks/exhaustive-deps

  // Reconcile markers with places + photos. Rebuilds the DOM node when any
  // visual input changes so photos, selection state and order badges stay
  // in sync.
  useEffect(() => {
    const map = mapRef.current
    if (!map) return
    const ids = new Set(places.map(p => p.id))

    markersRef.current.forEach((marker, id) => {
      if (!ids.has(id)) {
        marker.remove()
        markersRef.current.delete(id)
      }
    })

    places.forEach(place => {
      if (!place.lat || !place.lng) return
      const orderNumbers = dayOrderMap[place.id] ?? null
      const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
      const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
      const selected = place.id === selectedPlaceId
      const el = createMarkerElement(place as Place & { category_color?: string; category_icon?: string }, photoUrl, orderNumbers, selected)
      el.addEventListener('click', (ev) => {
        ev.stopPropagation()
        onClickRefs.current.marker?.(place.id)
      })
      // Recreate marker each time rather than patching internal state —
      // mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
      const existing = markersRef.current.get(place.id)
      if (existing) existing.remove()
      // Default (viewport-aligned) anchors keep the marker parallel to the
      // screen so its pixel centre lines up with the route line at any
      // pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
      // but it rotates the element by the pitch angle and visually offsets
      // the anchor by ~100px at 45° tilt, which caused the observed drift.
      const m = new mapboxgl.Marker({ element: el, anchor: 'center' })
        .setLngLat([place.lng, place.lat])
        .addTo(map)
      markersRef.current.set(place.id, m)
    })
  }, [places, selectedPlaceId, dayOrderMap, photoUrls])

  // Update route geojson
  useEffect(() => {
    const map = mapRef.current
    if (!map) return
    const src = map.getSource('trip-route') as mapboxgl.GeoJSONSource | undefined
    if (!src) return
    const features = (route || []).filter(seg => seg && seg.length > 1).map(seg => ({
      type: 'Feature' as const,
      properties: {},
      geometry: { type: 'LineString' as const, coordinates: seg.map(([lat, lng]) => [lng, lat]) },
    }))
    src.setData({ type: 'FeatureCollection', features })
  }, [route])

  // Travel times now live in the day sidebar (per-segment connectors), not on the map.

  // Update GPX geometries
  useEffect(() => {
    const map = mapRef.current
    if (!map) return
    const src = map.getSource('trip-gpx') as mapboxgl.GeoJSONSource | undefined
    if (!src) return
    const features = places.flatMap(place => {
      if (!place.route_geometry) return []
      try {
        const coords = JSON.parse(place.route_geometry) as [number, number][]
        if (!coords || coords.length < 2) return []
        return [{
          type: 'Feature' as const,
          properties: { color: (place as Place & { category_color?: string }).category_color || '#3b82f6' },
          geometry: { type: 'LineString' as const, coordinates: coords.map(([lat, lng]) => [lng, lat]) },
        }]
      } catch { return [] }
    })
    src.setData({ type: 'FeatureCollection', features })
  }, [places])

  // Reservation overlay — mirrors the Leaflet ReservationOverlay: great-
  // circle arcs for flights/cruises, straight lines for trains/cars,
  // clickable endpoint badges, rotating mid-arc stats label for flights.
  // The overlay is a small imperative manager that owns its own source,
  // layer, and HTML markers; it lives next to the map for the map's
  // lifetime and is rebuilt when the style/token/3d effect rebuilds.
  //
  // `visibleConnectionIds` is driven by the per-reservation toggle in
  // DayPlanSidebar — nothing is rendered until the user enables a
  // booking's route, matching the Leaflet MapView's behaviour.
  const visibleReservations = useMemo(() => {
    if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
    const set = new Set(visibleConnectionIds)
    return reservations.filter(r => set.has(r.id))
  }, [reservations, visibleConnectionIds])

  useEffect(() => {
    const map = mapRef.current
    if (!map || !mapReady) return
    if (!reservationOverlayRef.current) {
      reservationOverlayRef.current = new ReservationMapboxOverlay(map, {
        showConnections: true,
        showStats: showReservationStats,
        showEndpointLabels,
        onEndpointClick: (id) => onReservationClickRef.current?.(id),
      })
    }
    reservationOverlayRef.current.update(visibleReservations, {
      showConnections: true,
      showStats: showReservationStats,
      showEndpointLabels,
      onEndpointClick: (id) => onReservationClickRef.current?.(id),
    })
  }, [visibleReservations, showReservationStats, showEndpointLabels, mapReady])

  // Fit bounds on fitKey change — matches the Leaflet BoundsController
  const paddingOpts = useMemo(() => {
    const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
    if (isMobile) return { top: 40, right: 20, bottom: 40, left: 20 }
    const top = 60
    const bottom = hasInspector ? 320 : hasDayDetail ? 280 : 60
    return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
  }, [leftWidth, rightWidth, hasInspector, hasDayDetail])

  const prevFitKey = useRef(-1)
  useEffect(() => {
    if (fitKey === prevFitKey.current) return
    prevFitKey.current = fitKey
    const map = mapRef.current
    if (!map) return
    const target = dayPlaces.length > 0 ? dayPlaces : places
    const valid = target.filter(p => p.lat && p.lng)
    if (valid.length === 0) return
    const bounds = new mapboxgl.LngLatBounds()
    valid.forEach(p => bounds.extend([p.lng, p.lat]))
    const run = () => {
      try {
        map.fitBounds(bounds, {
          padding: paddingOpts,
          maxZoom: 15,
          pitch: mapbox3d ? 45 : 0,
          duration: 400,
        })
      } catch { /* noop */ }
    }
    if (map.loaded()) run()
    else map.once('load', run)
  }, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps

  // flyTo selected place
  useEffect(() => {
    const map = mapRef.current
    if (!map || !selectedPlaceId) return
    const target = places.find(p => p.id === selectedPlaceId) || dayPlaces.find(p => p.id === selectedPlaceId)
    if (!target?.lat || !target?.lng) return
    try {
      map.flyTo({
        center: [target.lng, target.lat],
        zoom: Math.max(map.getZoom(), 14),
        pitch: mapbox3d ? 45 : 0,
        duration: 400,
      })
    } catch { /* noop */ }
  }, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps

  // External center/zoom prop changes — jump without animation
  useEffect(() => {
    const map = mapRef.current
    if (!map) return
    try { map.jumpTo({ center: [center[1], center[0]], zoom }) } catch { /* noop */ }
  }, [center[0], center[1]]) // eslint-disable-line react-hooks/exhaustive-deps

  // Blue dot rendering + follow-mode camera. Attach the marker lazily the
  // first time a fix arrives so the layers sit on top of everything else
  // added so far, and destroy it when tracking is turned off.
  useEffect(() => {
    const map = mapRef.current
    if (!map) return
    if (trackingMode === 'off') {
      if (locationMarkerRef.current) {
        locationMarkerRef.current.update(null)
      }
      return
    }
    if (!userPosition) return
    const apply = () => {
      if (!locationMarkerRef.current) locationMarkerRef.current = attachLocationMarker(map)
      locationMarkerRef.current.update(userPosition)
      if (trackingMode === 'follow') {
        // easeTo is gentler than flyTo for continuous updates
        try {
          map.easeTo({
            center: [userPosition.lng, userPosition.lat],
            bearing: userPosition.heading ?? map.getBearing(),
            zoom: Math.max(map.getZoom(), 16),
            duration: 350,
          })
        } catch { /* noop */ }
      }
    }
    if (map.loaded()) apply()
    else map.once('load', apply)
  }, [userPosition, trackingMode])

  if (!mapboxToken) {
    return (
      <div className="w-full h-full flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 text-center px-6">
        <div className="text-sm text-zinc-500">
          No Mapbox access token configured.<br />
          <span className="text-xs">Settings → Map → Mapbox GL</span>
        </div>
      </div>
    )
  }

  // Desktop browsers only get IP-based geolocation (city-level accuracy),
  // so the button would be misleading. Mobile, where real GPS lives, keeps it.
  const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
  const buttonBottom = 'calc(var(--bottom-nav-h, 84px) + 12px)'

  return (
    <div className="w-full h-full relative">
      <div ref={containerRef} className="w-full h-full" />
      {isMobile && (
        <LocationButton
          mode={trackingMode}
          error={trackingError}
          onClick={cycleTrackingMode}
          bottomOffset={buttonBottom as unknown as number}
        />
      )}
    </div>
  )
}
