// assets/js/weather_map_hook.ts

import { updateGridOverlay } from "./maidenhead_grid"

// --- Type definitions ---

interface RGB {
  r: number
  g: number
  b: number
}

interface Breakpoint extends RGB {
  value: number
}

interface ContinuousColorScale {
  breakpoints: Breakpoint[]
  trueColor?: never
  format: (v: number | null) => string
  label: string
  unit: string
}

interface BooleanColorScale {
  breakpoints: null
  trueColor: RGB
  format: (v: boolean | null) => string
  label: string
  unit: string
}

type ColorScale = ContinuousColorScale | BooleanColorScale

type LayerId = keyof typeof COLOR_SCALES

interface WeatherPoint {
  lat: number
  lon: number
  valid_time: string | null
  temperature: number | null
  dewpoint_depression: number | null
  surface_rh: number | null
  surface_pressure_mb: number | null
  surface_refractivity: number | null
  refractivity_gradient: number | null
  bl_height: number | null
  pwat: number | null
  temp_850mb: number | null
  dewpoint_850mb: number | null
  lapse_rate: number | null
  inversion_strength: number | null
  inversion_base_m: number | null
  ducting: boolean | null
  duct_base_m: number | null
  duct_strength: number | null
  [key: string]: unknown
}

interface DetailRow {
  label?: string
  value?: string
  color?: string
  separator?: boolean
}

interface MapBounds {
  south: number
  north: number
  west: number
  east: number
}

interface WeatherMapHook extends ViewHook {
  map: L.Map
  weatherOverlay: L.GridLayer | null
  weatherData: WeatherPoint[]
  gridLookup: Map<string, WeatherPoint>
  selectedLayer: LayerId
  detailPanel: HTMLElement | null
  escHint: HTMLDivElement
  clickMarker: L.LayerGroup
  legend: L.Control
  gridLayer: L.LayerGroup
  gridVisible: boolean
  radarLayer: L.TileLayer.WMS | null
  radarRefreshTimer: ReturnType<typeof setInterval> | null
  timelineEl: HTMLElement | null
  timelineData: string[]
  selectedTime: string | null
  playbackTimer: ReturnType<typeof setInterval> | null
  _escHandler: (e: KeyboardEvent) => void
  _layerObserver: MutationObserver
  visibilityHandler: (() => void) | null

  mounted(this: WeatherMapHook): void
  destroyed(this: WeatherMapHook): void
  reconnected(this: WeatherMapHook): void
  sendBounds(this: WeatherMapHook): void
  positionDetailPanel(this: WeatherMapHook): void
  buildLookup(this: WeatherMapHook): void
  renderLayer(this: WeatherMapHook): void
  buildLegend(this: WeatherMapHook): HTMLElement
  renderTimeline(this: WeatherMapHook): void
  selectTimelineTime(this: WeatherMapHook, time: string): void
  startPlayback(this: WeatherMapHook): void
  stopPlayback(this: WeatherMapHook): void
}

// --- Helpers ---

function isMobile(): boolean {
  return window.innerWidth < 768
}

const COLOR_SCALES = {
  refractivity_gradient: {
    breakpoints: [
      { value: -500, r: 0,   g: 255, b: 163 },
      { value: -300, r: 125, g: 255, b: 212 },
      { value: -200, r: 255, g: 229, b: 102 },
      { value: -100, r: 255, g: 144, b: 68  },
      { value: 0,    r: 255, g: 79,  b: 79  }
    ],
    format: (v: number | null): string => v != null ? `${v.toFixed(0)} N/km` : "N/A",
    label: "Refractivity Gradient",
    unit: "N/km"
  },
  bl_height: {
    breakpoints: [
      { value: 0,    r: 0,   g: 255, b: 163 },
      { value: 500,  r: 125, g: 255, b: 212 },
      { value: 1000, r: 255, g: 229, b: 102 },
      { value: 2000, r: 255, g: 144, b: 68  },
      { value: 3000, r: 255, g: 79,  b: 79  }
    ],
    format: (v: number | null): string => v != null ? `${v.toFixed(0)} m` : "N/A",
    label: "Boundary Layer Height",
    unit: "m"
  },
  dewpoint_depression: {
    breakpoints: [
      { value: 0,  r: 0,   g: 255, b: 163 },
      { value: 5,  r: 125, g: 255, b: 212 },
      { value: 10, r: 255, g: 229, b: 102 },
      { value: 20, r: 255, g: 144, b: 68  },
      { value: 30, r: 255, g: 79,  b: 79  }
    ],
    format: (v: number | null): string => v != null ? `${v.toFixed(1)} \u00b0C` : "N/A",
    label: "Dewpoint Depression (T - Td)",
    unit: "\u00b0C"
  },
  pwat: {
    breakpoints: [
      { value: 0,  r: 139, g: 90,  b: 43  },
      { value: 15, r: 194, g: 165, b: 116 },
      { value: 30, r: 173, g: 216, b: 230 },
      { value: 45, r: 65,  g: 105, b: 225 },
      { value: 60, r: 0,   g: 0,   b: 180 }
    ],
    format: (v: number | null): string => v != null ? `${v.toFixed(1)} mm` : "N/A",
    label: "Precipitable Water",
    unit: "mm"
  },
  temperature: {
    breakpoints: [
      { value: -20, r: 0,   g: 0,   b: 200 },
      { value: 0,   r: 100, g: 149, b: 237 },
      { value: 15,  r: 255, g: 255, b: 150 },
      { value: 30,  r: 255, g: 140, b: 0   },
      { value: 45,  r: 200, g: 0,   b: 0   }
    ],
    format: (v: number | null): string => v != null ? `${v.toFixed(1)} \u00b0C (${(v * 9/5 + 32).toFixed(0)} \u00b0F)` : "N/A",
    label: "Surface Temperature",
    unit: "\u00b0C"
  },
  ducting: {
    breakpoints: null,
    trueColor: { r: 0, g: 255, b: 163 },
    format: (v: boolean | null): string => v ? "Yes" : "No",
    label: "Ducting Detected",
    unit: ""
  },
  surface_rh: {
    breakpoints: [
      { value: 0,   r: 255, g: 144, b: 68  },
      { value: 30,  r: 255, g: 229, b: 102 },
      { value: 60,  r: 125, g: 255, b: 212 },
      { value: 80,  r: 0,   g: 255, b: 163 },
      { value: 100, r: 0,   g: 200, b: 130 }
    ],
    format: (v: number | null): string => v != null ? `${v.toFixed(0)}%` : "N/A",
    label: "Relative Humidity",
    unit: "%"
  },
  surface_refractivity: {
    breakpoints: [
      { value: 250, r: 255, g: 79,  b: 79  },
      { value: 300, r: 255, g: 144, b: 68  },
      { value: 330, r: 255, g: 229, b: 102 },
      { value: 360, r: 125, g: 255, b: 212 },
      { value: 400, r: 0,   g: 255, b: 163 }
    ],
    format: (v: number | null): string => v != null ? `${v.toFixed(1)} N` : "N/A",
    label: "Surface Refractivity",
    unit: "N"
  },
  temp_850mb: {
    breakpoints: [
      { value: -20, r: 0,   g: 0,   b: 200 },
      { value: -5,  r: 100, g: 149, b: 237 },
      { value: 5,   r: 255, g: 255, b: 150 },
      { value: 15,  r: 255, g: 140, b: 0   },
      { value: 30,  r: 200, g: 0,   b: 0   }
    ],
    format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A",
    label: "Temperature at 850mb",
    unit: "°C"
  },
  dewpoint_850mb: {
    breakpoints: [
      { value: -20, r: 139, g: 90,  b: 43  },
      { value: -5,  r: 194, g: 165, b: 116 },
      { value: 5,   r: 173, g: 216, b: 230 },
      { value: 15,  r: 65,  g: 105, b: 225 },
      { value: 25,  r: 0,   g: 0,   b: 180 }
    ],
    format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C` : "N/A",
    label: "Dewpoint at 850mb",
    unit: "°C"
  },
  lapse_rate: {
    breakpoints: [
      { value: 0,   r: 0,   g: 255, b: 163 },
      { value: 4,   r: 125, g: 255, b: 212 },
      { value: 6.5, r: 255, g: 229, b: 102 },
      { value: 8,   r: 255, g: 144, b: 68  },
      { value: 10,  r: 255, g: 79,  b: 79  }
    ],
    format: (v: number | null): string => v != null ? `${v.toFixed(1)} °C/km` : "N/A",
    label: "Lapse Rate",
    unit: "°C/km"
  },
  inversion_strength: {
    breakpoints: [
      { value: 0,  r: 80,  g: 80,  b: 80  },
      { value: 1,  r: 255, g: 229, b: 102 },
      { value: 3,  r: 125, g: 255, b: 212 },
      { value: 5,  r: 0,   g: 255, b: 163 },
      { value: 10, r: 0,   g: 200, b: 130 }
    ],
    format: (v: number | null): string => v != null ? (v > 0 ? `+${v.toFixed(1)} °C` : "None") : "N/A",
    label: "Inversion Strength",
    unit: "°C"
  },
  inversion_base_m: {
    breakpoints: [
      { value: 0,    r: 0,   g: 255, b: 163 },
      { value: 500,  r: 125, g: 255, b: 212 },
      { value: 1000, r: 255, g: 229, b: 102 },
      { value: 2000, r: 255, g: 144, b: 68  },
      { value: 3000, r: 255, g: 79,  b: 79  }
    ],
    format: (v: number | null): string => v != null ? `${v.toFixed(0)} m AGL` : "None",
    label: "Inversion Base Height",
    unit: "m"
  },
  duct_base_m: {
    breakpoints: [
      { value: 0,    r: 0,   g: 255, b: 163 },
      { value: 200,  r: 125, g: 255, b: 212 },
      { value: 500,  r: 255, g: 229, b: 102 },
      { value: 1000, r: 255, g: 144, b: 68  },
      { value: 2000, r: 255, g: 79,  b: 79  }
    ],
    format: (v: number | null): string => v != null ? `${v.toFixed(0)} m AGL` : "None",
    label: "Duct Base Height",
    unit: "m"
  },
  duct_strength: {
    breakpoints: [
      { value: 0,  r: 80,  g: 80,  b: 80  },
      { value: 5,  r: 255, g: 229, b: 102 },
      { value: 10, r: 125, g: 255, b: 212 },
      { value: 20, r: 0,   g: 255, b: 163 },
      { value: 40, r: 0,   g: 200, b: 130 }
    ],
    format: (v: number | null): string => v != null ? (v > 0 ? `${v.toFixed(1)} M-units` : "None") : "N/A",
    label: "Duct Strength",
    unit: "M"
  }
} as const satisfies Record<string, ColorScale>

function interpolateColor(value: number | null, breakpoints: Breakpoint[]): RGB | null {
  if (value == null) return null
  if (value <= breakpoints[0].value) return breakpoints[0]
  if (value >= breakpoints[breakpoints.length - 1].value) return breakpoints[breakpoints.length - 1]

  for (let i = 0; i < breakpoints.length - 1; i++) {
    const lo = breakpoints[i]
    const hi = breakpoints[i + 1]
    if (value >= lo.value && value <= hi.value) {
      const t = (value - lo.value) / (hi.value - lo.value)
      return {
        r: Math.round(lo.r + (hi.r - lo.r) * t),
        g: Math.round(lo.g + (hi.g - lo.g) * t),
        b: Math.round(lo.b + (hi.b - lo.b) * t)
      }
    }
  }
  return breakpoints[breakpoints.length - 1]
}

function buildDetailHTML(point: WeatherPoint): string {
  if (!point) return ""

  const rows: DetailRow[] = [
    { label: "Temperature", value: COLOR_SCALES.temperature.format(point.temperature), color: "#ff9044" },
    { label: "Td Depression", value: COLOR_SCALES.dewpoint_depression.format(point.dewpoint_depression), color: "#7dffd4" },
    { label: "Rel. Humidity", value: COLOR_SCALES.surface_rh.format(point.surface_rh), color: "#00ffa3" },
    { label: "Sfc Pressure", value: point.surface_pressure_mb != null ? `${point.surface_pressure_mb.toFixed(1)} mb` : "N/A", color: "#aaa" },
    { label: "Refractivity", value: COLOR_SCALES.surface_refractivity.format(point.surface_refractivity), color: "#00ffa3" },
    { label: "N-Gradient", value: COLOR_SCALES.refractivity_gradient.format(point.refractivity_gradient), color: "#00ffa3" },
    { label: "BL Height", value: COLOR_SCALES.bl_height.format(point.bl_height), color: "#ffe566" },
    { label: "PWAT", value: COLOR_SCALES.pwat.format(point.pwat), color: "#4169e1" },
    { separator: true },
    { label: "T @ 850mb", value: COLOR_SCALES.temp_850mb.format(point.temp_850mb), color: "#ff9044" },
    { label: "Td @ 850mb", value: COLOR_SCALES.dewpoint_850mb.format(point.dewpoint_850mb), color: "#4169e1" },
    { label: "Lapse Rate", value: COLOR_SCALES.lapse_rate.format(point.lapse_rate), color: "#ffe566" },
    { label: "Inversion", value: COLOR_SCALES.inversion_strength.format(point.inversion_strength), color: point.inversion_strength != null && point.inversion_strength > 0 ? "#00ffa3" : "#666" },
    { label: "Inv. Base", value: COLOR_SCALES.inversion_base_m.format(point.inversion_base_m), color: point.inversion_base_m != null ? "#7dffd4" : "#666" },
    { separator: true },
    { label: "Ducting", value: COLOR_SCALES.ducting.format(point.ducting), color: point.ducting ? "#00ffa3" : "#ff4f4f" },
    { label: "Duct Base", value: COLOR_SCALES.duct_base_m.format(point.duct_base_m), color: point.duct_base_m != null ? "#00ffa3" : "#666" },
    { label: "Duct Strength", value: COLOR_SCALES.duct_strength.format(point.duct_strength), color: point.duct_strength != null ? "#00ffa3" : "#666" }
  ]

  const mobile = isMobile()
  const closeBtn = mobile
    ? `<button onclick="document.getElementById('weather-detail-panel').style.display='none'" style="position:absolute;top:8px;right:10px;background:rgba(255,255,255,0.15);border:none;color:#fff;font-size:18px;line-height:1;width:28px;height:28px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;">&times;</button>`
    : ""
  const handle = mobile
    ? `<div style="display:flex;justify-content:center;padding:6px 0 2px;"><div style="width:32px;height:4px;border-radius:2px;background:rgba(255,255,255,0.3);"></div></div>`
    : ""

  const tableRows = rows.map(r => {
    if (r.separator) {
      return `<tr><td colspan="2" style="padding:2px 0;"><hr style="border:none;border-top:1px solid rgba(255,255,255,0.1);"></td></tr>`
    }
    return `<tr>
      <td style="padding:3px 8px 3px 0;font-size:12px;white-space:nowrap;"><span style="color:${r.color};">\u25cf</span> ${r.label}</td>
      <td style="padding:3px 0;font-size:12px;text-align:right;font-weight:600;">${r.value}</td>
    </tr>`
  }).join("")

  const time = point.valid_time
    ? new Date(point.valid_time).toISOString().replace("T", " ").slice(0, 16) + " UTC"
    : ""

  return `<div style="min-width:${mobile ? 'auto' : '240px'};position:relative;">
    ${handle}${closeBtn}
    <div style="background:rgba(255,255,255,0.1);padding:6px 10px;">
      <strong style="font-size:14px;">Weather Details</strong>
    </div>
    <div style="padding:6px 10px;">
      <div style="font-size:11px;opacity:0.6;margin-bottom:6px;">
        ${point.lat.toFixed(3)}\u00b0N, ${Math.abs(point.lon).toFixed(3)}\u00b0W &mdash; ${time}
      </div>
      <table style="width:100%;border-collapse:collapse;">${tableRows}</table>
    </div>
  </div>`
}

// --- Hook ---

export const WeatherMap: WeatherMapHook = {
  mounted(this: WeatherMapHook) {
    this.map = L.map(this.el, {
      center: [32.897, -97.038],
      zoom: 7,
      minZoom: 4,
      maxZoom: 10
    })

    L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
      attribution: "&copy; OpenStreetMap contributors",
      maxZoom: 19
    }).addTo(this.map)

    this.weatherOverlay = null
    this.weatherData = []
    this.gridLookup = new Map()
    this.selectedLayer = (this.el.dataset.selectedLayer || "refractivity_gradient") as LayerId

    const storedLayer = localStorage.getItem("weatherMap.selectedLayer")
    if (storedLayer && storedLayer in COLOR_SCALES && storedLayer !== this.selectedLayer) {
      this.selectedLayer = storedLayer as LayerId
      this.pushEvent("select_layer", { layer: storedLayer })
    }

    const initialData: WeatherPoint[] = JSON.parse(this.el.dataset.weather || "[]")
    if (initialData.length > 0) {
      this.weatherData = initialData
      this.buildLookup()
      this.renderLayer()
    }

    this.handleEvent("update_weather", ({ data }: { data: WeatherPoint[] }) => {
      this.weatherData = data
      this.buildLookup()
      this.renderLayer()
    })

    this.detailPanel = document.getElementById("weather-detail-panel")
    if (this.detailPanel) {
      L.DomEvent.disableClickPropagation(this.detailPanel)
      L.DomEvent.disableScrollPropagation(this.detailPanel)
    }

    // ESC hint
    this.escHint = document.createElement("div")
    this.escHint.className = "hidden md:block"
    this.escHint.style.cssText = "position:absolute;top:12px;right:12px;z-index:1100;background:rgba(0,0,0,0.6);color:#fff;padding:4px 10px;border-radius:6px;font-size:12px;opacity:0;transition:opacity 0.2s;pointer-events:none;"
    this.escHint.textContent = "Press ESC to close"
    this.el.appendChild(this.escHint)

    this.handleEvent("point_detail", (detail: WeatherPoint) => {
      if (detail && detail.lat && this.detailPanel) {
        this.detailPanel.innerHTML = buildDetailHTML(detail)
        this.detailPanel.style.display = "block"
        this.escHint.style.opacity = "1"
        this.positionDetailPanel()
      }
    })

    this.clickMarker = L.layerGroup().addTo(this.map)

    this.map.on("click", (e: L.LeafletMouseEvent) => {
      this.clickMarker.clearLayers()
      L.circleMarker([e.latlng.lat, e.latlng.lng], {
        radius: 6, color: "#fff", weight: 2,
        fillColor: "#888", fillOpacity: 0.8, interactive: false
      }).addTo(this.clickMarker)
      this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
    })

    this._escHandler = (e: KeyboardEvent) => {
      if (e.key === "Escape" && this.detailPanel) {
        this.detailPanel.style.display = "none"
        this.detailPanel.innerHTML = ""
        this.clickMarker.clearLayers()
        this.escHint.style.opacity = "0"
      }
    }
    document.addEventListener("keydown", this._escHandler)

    const controls = document.getElementById("weather-controls")
    if (controls) {
      L.DomEvent.disableClickPropagation(controls)
      L.DomEvent.disableScrollPropagation(controls)
    }

    // Maidenhead grid overlay
    this.gridLayer = L.layerGroup()
    this.gridVisible = false

    this.handleEvent("toggle_grid", ({ visible }: { visible: boolean }) => {
      this.gridVisible = visible
      localStorage.setItem("weatherMap.gridVisible", String(visible))
      if (visible) {
        this.gridLayer.addTo(this.map)
        updateGridOverlay(this.map, this.gridLayer)
      } else {
        this.gridLayer.remove()
      }
    })

    if (localStorage.getItem("weatherMap.gridVisible") === "true") {
      this.pushEvent("toggle_grid", {})
    }

    // Weather radar overlay (ECCC GeoMet WMS — CONUS + Canada composite dBZ).
    // Lazy-created on first toggle so we don't hit the service until asked.
    this.radarLayer = null
    this.radarRefreshTimer = null

    this.handleEvent("toggle_radar", ({ visible }: { visible: boolean }) => {
      localStorage.setItem("weatherMap.radarVisible", String(visible))
      if (visible) {
        if (!this.radarLayer) {
          this.radarLayer = L.tileLayer.wms("https://geo.weather.gc.ca/geomet", {
            layers: "Radar_1km_dBZ-Extrapolation",
            format: "image/png",
            transparent: true,
            opacity: 0.55,
            version: "1.3.0",
            attribution: "Radar &copy; Environment and Climate Change Canada"
          })
        }
        this.radarLayer.addTo(this.map)
        // ECCC radar updates every ~6 minutes; force a tile refresh on that
        // cadence so the overlay stays current without a page reload.
        this.radarRefreshTimer = setInterval(() => {
          if (this.radarLayer) {
            // @ts-expect-error setParams exists on WMS TileLayer but isn't in @types/leaflet yet
            this.radarLayer.setParams({ _: Date.now() })
          }
        }, 6 * 60 * 1000)
      } else {
        if (this.radarLayer) this.radarLayer.remove()
        if (this.radarRefreshTimer) {
          clearInterval(this.radarRefreshTimer)
          this.radarRefreshTimer = null
        }
      }
    })

    if (localStorage.getItem("weatherMap.radarVisible") === "true") {
      this.pushEvent("toggle_radar", {})
    }

    requestAnimationFrame(() => this.sendBounds())
    this.map.on("moveend", () => {
      this.sendBounds()
      if (this.gridVisible) {
        updateGridOverlay(this.map, this.gridLayer)
      }
    })

    // Tab may be hidden while Leaflet drops/creates tiles; retained
    // gridLookup stops matching the visible viewport and new tiles paint
    // blank. Re-fetch bounds on return so update_weather rebuilds the layer.
    this.visibilityHandler = () => {
      if (document.visibilityState === "visible") {
        this.map.invalidateSize()
        this.sendBounds()
      }
    }
    document.addEventListener("visibilitychange", this.visibilityHandler)

    this._layerObserver = new MutationObserver(() => {
      const newLayer = this.el.dataset.selectedLayer as LayerId | undefined
      if (newLayer && newLayer !== this.selectedLayer) {
        this.selectedLayer = newLayer
        localStorage.setItem("weatherMap.selectedLayer", newLayer)
        this.renderLayer()
      }
    })
    this._layerObserver.observe(this.el, { attributes: true, attributeFilter: ["data-selected-layer"] })

    this.legend = L.control({ position: "topright" })
    this.legend.onAdd = () => this.buildLegend()
    this.legend.addTo(this.map)

    // --- Forecast timeline ---
    this.timelineEl = document.getElementById("weather-forecast-timeline")
    this.timelineData = JSON.parse(this.el.dataset.validTimes || "[]")
    this.selectedTime = this.el.dataset.selectedTime || null
    this.playbackTimer = null

    if (this.timelineData.length > 1) {
      this.renderTimeline()
    }

    this.handleEvent("update_timeline", ({ times, selected }: { times: string[]; selected: string | null }) => {
      this.timelineData = times
      if (selected) this.selectedTime = selected
      this.renderTimeline()
    })

    // Prevent timeline from eating map clicks
    if (this.timelineEl) {
      L.DomEvent.disableClickPropagation(this.timelineEl)
      L.DomEvent.disableScrollPropagation(this.timelineEl)
    }
  },

  destroyed(this: WeatherMapHook) {
    if (this._layerObserver) this._layerObserver.disconnect()
    if (this._escHandler) document.removeEventListener("keydown", this._escHandler)
    if (this.radarRefreshTimer) clearInterval(this.radarRefreshTimer)
    if (this.playbackTimer !== null) clearInterval(this.playbackTimer)
    if (this.visibilityHandler) {
      document.removeEventListener("visibilitychange", this.visibilityHandler)
      this.visibilityHandler = null
    }
  },

  reconnected(this: WeatherMapHook) {
    if (this.map) {
      this.map.invalidateSize()
      this.sendBounds()
    }
  },

  sendBounds(this: WeatherMapHook) {
    const b = this.map.getBounds()
    this.pushEvent("map_bounds", {
      south: b.getSouth(), north: b.getNorth(),
      west: b.getWest(), east: b.getEast()
    })
  },

  positionDetailPanel(this: WeatherMapHook) {
    if (!this.detailPanel || isMobile()) return
    const controls = document.getElementById("weather-controls")
    if (controls) {
      const rect = controls.getBoundingClientRect()
      this.detailPanel.style.left = rect.left + "px"
      this.detailPanel.style.top = (rect.bottom + 8) + "px"
    }
  },

  buildLookup(this: WeatherMapHook) {
    this.gridLookup = new Map()
    this.weatherData.forEach(d => {
      const key = `${d.lat.toFixed(3)},${d.lon.toFixed(3)}`
      this.gridLookup.set(key, d)
    })
  },

  renderLayer(this: WeatherMapHook) {
    if (this.weatherOverlay) {
      this.map.removeLayer(this.weatherOverlay)
      this.weatherOverlay = null
    }
    if (this.weatherData.length === 0) return

    const layerId = this.selectedLayer
    const scale = COLOR_SCALES[layerId]
    if (!scale) return

    const self = this
    const isDucting = layerId === "ducting"

    const Overlay = L.GridLayer.extend({
      createTile(this: L.GridLayer, coords: L.Coords) {
        const tile = document.createElement("canvas")
        const size = this.getTileSize()
        tile.width = size.x
        tile.height = size.y
        const ctx = tile.getContext("2d")!

        const step = 0.125
        const nwPoint = coords.scaleBy(size)
        const nw = (this as any)._map.unproject(nwPoint, coords.z) as L.LatLng
        const se = (this as any)._map.unproject(nwPoint.add(size), coords.z) as L.LatLng

        const latPerPx = (nw.lat - se.lat) / size.y
        const lonPerPx = (se.lng - nw.lng) / size.x
        const cellPxX = Math.max(1, Math.ceil(step / lonPerPx))
        const cellPxY = Math.max(1, Math.ceil(step / Math.abs(latPerPx)))
        const absLatPerPx = Math.abs(latPerPx)

        for (let py = 0; py < size.y; py += cellPxY) {
          const lat = nw.lat - py * absLatPerPx
          for (let px = 0; px < size.x; px += cellPxX) {
            const lon = nw.lng + px * lonPerPx
            const rlat = (Math.round(lat / step) * step).toFixed(3)
            const rlon = (Math.round(lon / step) * step).toFixed(3)
            const point = self.gridLookup.get(`${rlat},${rlon}`)
            if (!point) continue

            const value = point[layerId]

            if (isDucting) {
              if (value) {
                const tc = (scale as BooleanColorScale).trueColor
                ctx.fillStyle = `rgba(${tc.r},${tc.g},${tc.b},0.55)`
                ctx.fillRect(px, py, cellPxX, cellPxY)
              }
            } else {
              const c = interpolateColor(value as number | null, (scale as ContinuousColorScale).breakpoints)
              if (c) {
                ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},0.55)`
                ctx.fillRect(px, py, cellPxX, cellPxY)
              }
            }
          }
        }
        return tile
      }
    })

    this.weatherOverlay = new Overlay({ opacity: 1.0 }) as L.GridLayer
    this.weatherOverlay.addTo(this.map)

    if (this.legend) {
      this.map.removeControl(this.legend)
      this.legend = L.control({ position: "topright" })
      this.legend.onAdd = () => this.buildLegend()
      this.legend.addTo(this.map)
    }
  },

  buildLegend(this: WeatherMapHook): HTMLElement {
    const div = L.DomUtil.create("div")
    const layerId = this.selectedLayer
    const scale = COLOR_SCALES[layerId]
    if (!scale) return div

    const mobile = isMobile()

    if (layerId === "ducting") {
      const c = (scale as BooleanColorScale).trueColor
      div.style.cssText = `background:#fff;color:#333;padding:${mobile ? '4px 6px' : '10px 14px'};border-radius:${mobile ? '6px' : '8px'};box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:${mobile ? '10px' : '13px'};line-height:2;`
      div.innerHTML = `<strong style="color:#333;">${scale.label}</strong><br>` +
        `<span style="background:rgb(${c.r},${c.g},${c.b});width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span>Ducting<br>` +
        `<span style="background:transparent;width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span>None`
      return div
    }

    const bp = (scale as ContinuousColorScale).breakpoints

    if (mobile) {
      div.style.cssText = "background:#fff;color:#333;padding:4px 6px;border-radius:6px;box-shadow:0 1px 4px rgba(0,0,0,0.3);font-size:10px;line-height:1.6;"
      div.innerHTML = bp.map(b =>
        `<span style="background:rgb(${b.r},${b.g},${b.b});width:10px;height:10px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:3px;border:1px solid rgba(0,0,0,0.15);"></span>${b.value}`
      ).join("<br>")
    } else {
      div.style.cssText = "background:#fff;color:#333;padding:10px 14px;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,0.3);font-size:13px;line-height:2;"
      div.innerHTML = `<strong style="color:#333;">${scale.label}</strong><br>` +
        bp.map(b =>
          `<span style="background:rgb(${b.r},${b.g},${b.b});width:14px;height:14px;display:inline-block;border-radius:50%;vertical-align:middle;margin-right:6px;border:1px solid rgba(0,0,0,0.15);"></span><span style="color:#333;">${b.value} ${scale.unit}</span>`
        ).join("<br>")
    }
    return div
  },

  renderTimeline(this: WeatherMapHook) {
    if (!this.timelineEl || this.timelineData.length === 0) {
      if (this.timelineEl) this.timelineEl.style.display = "none"
      return
    }

    if (this.timelineData.length === 1) {
      const dt = new Date(this.timelineData[0])
      const utcLabel = `${dt.getUTCHours().toString().padStart(2, "0")}:00 UTC`
      const dateLabel = `${dt.getUTCFullYear()}-${(dt.getUTCMonth() + 1).toString().padStart(2, "0")}-${dt.getUTCDate().toString().padStart(2, "0")}`
      this.timelineEl.style.display = "block"
      this.timelineEl.innerHTML = `
        <div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:6px 14px;display:flex;gap:8px;align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);font-size:11px;color:rgba(255,255,255,0.6);">
          <span>Data: ${dateLabel} ${utcLabel}</span>
        </div>`
      return
    }

    const now = new Date()
    type TItem = { time: string; dt: Date; isSelected: boolean; isPast: boolean; offsetH: number; label: string }
    const items: TItem[] = this.timelineData.map((time) => {
      const dt = new Date(time)
      const isSelected = time === this.selectedTime
      const isPast = dt.getTime() < now.getTime() - 1800000
      return { time, dt, isSelected, isPast, offsetH: 0, label: "" }
    })

    const closestIdx = items.reduce((best, item, i) =>
      Math.abs(item.dt.getTime() - now.getTime()) < Math.abs(items[best].dt.getTime() - now.getTime()) ? i : best, 0)

    // Label offsets relative to the "Now" slot, not wall-clock. If we
    // round from wall-clock, the hour after "Now" rounds to "0h" when
    // the clock is past the half-hour, which reads as a second "now".
    const anchorMs = items[closestIdx].dt.getTime()
    items.forEach((t, i) => {
      const diff = Math.round((t.dt.getTime() - anchorMs) / 3600000)
      t.offsetH = diff
      if (i === closestIdx) {
        t.label = "Now"
      } else {
        t.label = diff > 0 ? `+${diff}h` : `${diff}h`
      }
    })

    const mobile = isMobile()
    const btnPad = mobile ? "3px 5px" : "4px 8px"
    const btnMinW = mobile ? "32px" : "38px"
    const btnFont = mobile ? "10px" : "11px"
    const subFont = mobile ? "8px" : "9px"

    const buttons = items.map(t => {
      const bg = t.isSelected ? "#7dffd4" : (t.isPast ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.15)")
      const fg = t.isSelected ? "#000" : (t.isPast ? "rgba(255,255,255,0.4)" : "#fff")
      const border = t.isSelected ? "2px solid #7dffd4" : "1px solid rgba(255,255,255,0.2)"
      const weight = t.isSelected ? "700" : "400"
      const utcLabel = `${t.dt.getUTCHours().toString().padStart(2, "0")}:00`
      return `<button data-time="${t.time}" style="
        padding:${btnPad};font-size:${btnFont};font-weight:${weight};
        background:${bg};color:${fg};border:${border};border-radius:6px;
        cursor:pointer;white-space:nowrap;min-width:${btnMinW};
        transition:background 0.15s;
      ">${t.label}<br><span style="font-size:${subFont};opacity:0.7;">${utcLabel}</span></button>`
    }).join("")

    const labelText = mobile ? "Forecast" : "Weather Forecast"
    const isPlaying = this.playbackTimer !== null
    const playBg = isPlaying ? "#7dffd4" : "rgba(255,255,255,0.15)"
    const playFg = isPlaying ? "#000" : "#fff"
    const ctrlFont = mobile ? "10px" : "11px"
    const ctrlPad = mobile ? "1px 5px" : "2px 7px"

    this.timelineEl.style.display = "block"
    this.timelineEl.innerHTML = `
      <div style="background:rgba(0,0,0,0.85);border-radius:12px;padding:${mobile ? "4px 6px" : "6px 10px"};display:flex;gap:${mobile ? "2px" : "3px"};align-items:center;box-shadow:0 2px 12px rgba(0,0,0,0.4);overflow-x:auto;max-width:calc(100vw - 1rem);">
        <div style="display:flex;flex-direction:column;align-items:center;gap:3px;margin-right:6px;flex-shrink:0;">
          <span style="font-size:${mobile ? "9px" : "10px"};color:rgba(255,255,255,0.5);white-space:nowrap;">${labelText}</span>
          <div style="display:flex;gap:3px;">
            <button data-timeline-play style="
              padding:${ctrlPad};font-size:${ctrlFont};line-height:1;
              background:${playBg};color:${playFg};
              border:1px solid rgba(255,255,255,0.2);border-radius:4px;
              cursor:pointer;" title="Play forecast animation">▶</button>
            <button data-timeline-stop style="
              padding:${ctrlPad};font-size:${ctrlFont};line-height:1;
              background:rgba(255,255,255,0.15);color:#fff;
              border:1px solid rgba(255,255,255,0.2);border-radius:4px;
              cursor:pointer;" title="Stop and return to now">■</button>
          </div>
        </div>
        ${buttons}
      </div>`

    this.timelineEl.querySelectorAll<HTMLButtonElement>("button[data-time]").forEach(btn => {
      btn.addEventListener("click", () => {
        const time = btn.dataset.time!
        // A manual click interrupts playback — the user has taken over.
        this.stopPlayback()
        this.selectTimelineTime(time)
      })
    })

    const playBtn = this.timelineEl.querySelector<HTMLButtonElement>("button[data-timeline-play]")
    if (playBtn) playBtn.addEventListener("click", () => this.startPlayback())

    const stopBtn = this.timelineEl.querySelector<HTMLButtonElement>("button[data-timeline-stop]")
    if (stopBtn) stopBtn.addEventListener("click", () => this.stopPlayback())
  },

  selectTimelineTime(this: WeatherMapHook, time: string) {
    this.selectedTime = time
    this.renderTimeline()
    this.pushEvent("select_time", { time })
  },

  startPlayback(this: WeatherMapHook) {
    // Restarting re-seeds at "now" so repeated clicks don't resume
    // mid-loop — users expect "play from the start".
    if (this.playbackTimer !== null) {
      clearInterval(this.playbackTimer)
      this.playbackTimer = null
    }

    if (this.timelineData.length < 2) return

    const now = Date.now()
    const nowIdx = this.timelineData.reduce((best, time, i) => {
      const d = Math.abs(new Date(time).getTime() - now)
      const bestD = Math.abs(new Date(this.timelineData[best]).getTime() - now)
      return d < bestD ? i : best
    }, 0)

    // Iterate "now" forward through the end of the forecast then loop.
    const playable = this.timelineData.slice(nowIdx)
    if (playable.length < 2) return

    let cursor = 0
    const step = () => {
      this.selectTimelineTime(playable[cursor])
      cursor = (cursor + 1) % playable.length
    }

    // Assign the handle before the first step so renderTimeline sees
    // playbackTimer !== null and paints Play as active.
    this.playbackTimer = setInterval(step, 1000)
    step()
  },

  stopPlayback(this: WeatherMapHook) {
    if (this.playbackTimer !== null) {
      clearInterval(this.playbackTimer)
      this.playbackTimer = null
    }

    if (this.timelineData.length === 0) {
      this.renderTimeline()
      return
    }

    const now = Date.now()
    const nowIdx = this.timelineData.reduce((best, time, i) => {
      const d = Math.abs(new Date(time).getTime() - now)
      const bestD = Math.abs(new Date(this.timelineData[best]).getTime() - now)
      return d < bestD ? i : best
    }, 0)
    this.selectTimelineTime(this.timelineData[nowIdx])
  }
} as unknown as WeatherMapHook
