import topbar from "../vendor/topbar"
import { formatDistanceKm } from "./format"
import { updateGridOverlay } from "./maidenhead_grid"

const KM2MI = 0.621371

function kmRangeToMi(lowKm: number, highKm: number): string {
  const lowMi = Math.round(lowKm * KM2MI)
  const highMi = Math.round(highKm * KM2MI)
  return `${lowMi}\u2013${highMi} mi (${Math.round(lowKm)}\u2013${Math.round(highKm)} km)`
}

function kmRangeOpenToMi(lowKm: number): string {
  const lowMi = Math.round(lowKm * KM2MI)
  return `${lowMi}+ mi (${Math.round(lowKm)}+ km)`
}

function kmCeilToMi(highKm: number): string {
  const highMi = Math.round(highKm * KM2MI)
  return `<${highMi} mi (<${Math.round(highKm)} km)`
}

// --- Interfaces ---

interface FactorMeta {
  label: string
  weight: number
  unit: string
}

interface ScoreTier {
  label: string
  color: string
}

interface ColorScaleEntry {
  min: number
  r: number
  g: number
  b: number
}

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

interface LatLon {
  lat: number
  lon: number
}

interface ScorePoint {
  lat: number
  lon: number
  score: number
  [key: string]: unknown
}

interface ForecastPoint {
  score: number
  time: string
}

interface SvgPoint {
  x: number
  y: number
  score: number
  time: Date
}

interface ScatterCell {
  lat: number
  lon: number
  dbz: number
  bearing: number
  distance_km: number
  scatter_db: number
}

interface RainScatter {
  classification: string
  cells: ScatterCell[]
}

interface DuctLayer {
  base_m: number
  top_m: number
  thickness_m: number
  min_freq_ghz: number | null
}

interface DuctInfo {
  duct_count: number
  ducts: DuctLayer[]
}

interface PointDetail {
  score: number
  lat: number
  lon: number
  valid_time: string
  band_label: string
  humidity_effect: string
  typical_range_km: number
  extended_range_km: number
  exceptional_range_km: number
  factors: Record<string, number> & { duct_info?: DuctInfo }
  rain_scatter?: RainScatter | "pending"
  forecast?: ForecastPoint[]
  [key: string]: unknown
}

interface BandInfo {
  band_label?: string
  humidity_effect?: string
  typical_range_km?: number
  extended_range_km?: number
  exceptional_range_km?: number
  [key: string]: unknown
}

interface TimelineEntry {
  time: string
  [key: string]: unknown
}

interface TimelineItem extends TimelineEntry {
  dt: Date
  isSelected: boolean
  isPast: boolean
  offsetH: number
  idx: number
  label: string
}

interface ViewshedResult {
  origin: LatLon
  points: LatLon[]
}

/** Hook state that extends Phoenix LiveView's ViewHook */
interface PropagationMapHook extends ViewHook {
  map: L.Map
  scoreOverlay: L.GridLayer | null
  scores: ScorePoint[]
  colorScale: ColorScaleEntry[]
  scoreGrid: ScoreGrid
  bandInfo: BandInfo
  rangeCircles: L.LayerGroup
  gridLayer: L.LayerGroup
  gridVisible: boolean
  radarLayer: L.TileLayer.WMS | null
  radarRefreshTimer: ReturnType<typeof setInterval> | null
  detailPanel: HTMLElement | null
  viewshedLoading: boolean
  lastDetail: PointDetail | null
  clickedLatLng: [number, number] | null
  scatterMarkers: L.LayerGroup | null
  reachPolygon: L.Polygon | null
  timelineEl: HTMLElement | null
  timelineData: TimelineEntry[]
  selectedTime: string | null
  forecastCache: Map<string, ScorePoint[]>
  playbackTimer: ReturnType<typeof setInterval> | null
  visibilityHandler: (() => void) | null

  showDetailPanel(this: PropagationMapHook): void
  hideDetailPanel(this: PropagationMapHook): void
  redrawReachPolygon(this: PropagationMapHook): void
  renderScores(this: PropagationMapHook, scores: ScorePoint[]): void
  drawTileCanvas(this: PropagationMapHook, canvas: HTMLCanvasElement, coords: L.Coords, layer: L.GridLayer): void
  interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null
  scoreColorRGB(this: PropagationMapHook, score: number): RGB
  lookupPoint(this: PropagationMapHook, lat: number, lon: number): (ScorePoint & BandInfo) | null
  drawScatterMarkers(this: PropagationMapHook, scatter: RainScatter): void
  renderTimeline(this: PropagationMapHook): void
  selectTimelineTime(this: PropagationMapHook, time: string): void
  startPlayback(this: PropagationMapHook): void
  stopPlayback(this: PropagationMapHook): void
  sendBounds(this: PropagationMapHook): void
  colorCache: string[]
}

// --- Constants ---

// CONUS propagation grid (must match Microwaveprop.Propagation.Grid in Elixir).
const GRID_LAT_MIN = 25.0
const GRID_LON_MIN = -125.0
const GRID_STEP = 0.125
const GRID_LAT_COUNT = 201  // (50 - 25) / 0.125 + 1
const GRID_LON_COUNT = 473  // (-66 - -125) / 0.125 + 1
const GRID_MISSING = -1     // Int8Array sentinel for empty cell

/**
 * Flat typed-array grid storing propagation scores at 0.125° resolution across
 * CONUS. Replaces a string-keyed Map — zero allocations on per-pixel reads,
 * which matters because `interpolateScore` is called thousands of times per
 * rendered canvas tile.
 */
class ScoreGrid {
  private readonly data: Int8Array

  constructor() {
    this.data = new Int8Array(GRID_LAT_COUNT * GRID_LON_COUNT)
    this.data.fill(GRID_MISSING)
  }

  reset(): void {
    this.data.fill(GRID_MISSING)
  }

  put(lat: number, lon: number, score: number): void {
    const latIdx = Math.round((lat - GRID_LAT_MIN) / GRID_STEP)
    const lonIdx = Math.round((lon - GRID_LON_MIN) / GRID_STEP)
    if (latIdx < 0 || latIdx >= GRID_LAT_COUNT) return
    if (lonIdx < 0 || lonIdx >= GRID_LON_COUNT) return
    this.data[latIdx * GRID_LON_COUNT + lonIdx] = score
  }

  get(lat: number, lon: number): number | null {
    const latIdx = Math.round((lat - GRID_LAT_MIN) / GRID_STEP)
    const lonIdx = Math.round((lon - GRID_LON_MIN) / GRID_STEP)
    return this.getByIdx(latIdx, lonIdx)
  }

  getByIdx(latIdx: number, lonIdx: number): number | null {
    if (latIdx < 0 || latIdx >= GRID_LAT_COUNT) return null
    if (lonIdx < 0 || lonIdx >= GRID_LON_COUNT) return null
    const v = this.data[latIdx * GRID_LON_COUNT + lonIdx]
    return v === GRID_MISSING ? null : v
  }

  indexOf(lat: number, lon: number): number {
    const latIdx = Math.round((lat - GRID_LAT_MIN) / GRID_STEP)
    const lonIdx = Math.round((lon - GRID_LON_MIN) / GRID_STEP)
    if (latIdx < 0 || latIdx >= GRID_LAT_COUNT) return -1
    if (lonIdx < 0 || lonIdx >= GRID_LON_COUNT) return -1
    return latIdx * GRID_LON_COUNT + lonIdx
  }
}

// Weights must match BandConfig.weights() in band_config.ex
// Recalibrated 2026-04-11 via gradient descent (loss 0.42 → 0.12)
const FACTOR_META: Record<string, FactorMeta> = {
  rain:          { label: "Rain",           weight: 0.1362, unit: "" },
  humidity:      { label: "Humidity",       weight: 0.1243, unit: "g/m\u00b3" },
  pwat:          { label: "PWAT",           weight: 0.1128, unit: "mm" },
  season:        { label: "Season",         weight: 0.1112, unit: "" },
  refractivity:  { label: "Refractivity",   weight: 0.1049, unit: "N/km" },
  pressure:      { label: "Pressure",       weight: 0.1032, unit: "mb" },
  td_depression: { label: "Td Depression",  weight: 0.0978, unit: "\u00b0F" },
  sky:           { label: "Sky Cover",      weight: 0.08,   unit: "%" },
  wind:          { label: "Wind",           weight: 0.08,   unit: "kts" },
  time_of_day:   { label: "Time of Day",    weight: 0.0496, unit: "" }
}

const FACTOR_ORDER: string[] = [
  "rain", "humidity", "pwat", "season", "refractivity",
  "pressure", "td_depression", "sky", "wind", "time_of_day"
]

// --- Helper functions ---

function scoreTier(score: number): ScoreTier {
  if (score >= 80) return { label: "EXCELLENT", color: "#00ffa3" }
  if (score >= 65) return { label: "GOOD", color: "#7dffd4" }
  if (score >= 50) return { label: "MARGINAL", color: "#ffe566" }
  if (score >= 33) return { label: "POOR", color: "#ff9044" }
  return { label: "NEGLIGIBLE", color: "#ff4f4f" }
}

function factorBar(score: number): string {
  const filled = Math.round(score / 5)
  const empty = 20 - filled
  const tier = scoreTier(score)
  return `<span style="color:${tier.color}">${"\u2588".repeat(filled)}</span><span style="color:rgba(255,255,255,0.2)">${"\u2591".repeat(empty)}</span>`
}

function factorExplanation(key: string, score: number, detail: PointDetail): string {
  const beneficial = detail.humidity_effect === "beneficial"

  switch (key) {
    case "humidity":
      if (beneficial) {
        if (score >= 80) return "High moisture — strong ducting potential"
        if (score >= 55) return "Moderate moisture — some ducting possible"
        return "Dry air — ducting unlikely"
      } else {
        if (score >= 80) return "Low humidity — minimal absorption"
        if (score >= 50) return "Moderate humidity — some absorption"
        return "High humidity — significant absorption at this frequency"
      }
    case "time_of_day":
      if (score >= 80) return "Sunrise window — peak inversion strength"
      if (score >= 65) return "Evening/post-sunrise — inversions forming or eroding"
      if (score >= 40) return "Night — gradual cooling, weak inversions"
      return "Afternoon — convective mixing destroys inversions"
    case "td_depression":
      if (beneficial) {
        if (score >= 75) return "Tight depression — moist boundary layer favors ducting"
        if (score >= 50) return "Moderate spread — some moisture present"
        return "Wide spread — dry air limits ducting formation"
      } else {
        if (score >= 80) return "Wide spread — dry air reduces absorption"
        if (score >= 50) return "Moderate spread"
        return "Tight depression — moisture increasing absorption"
      }
    case "refractivity":
      if (score >= 80) return "Strong inversion gradient — ducting layer detected"
      if (score >= 60) return "Moderate gradient — enhanced refraction"
      if (score >= 45) return "Weak gradient — near standard atmosphere"
      return "No significant inversion — standard refraction"
    case "sky":
      if (score >= 80) return "Clear skies — radiative cooling strengthens inversions"
      if (score >= 50) return "Partly cloudy — some radiative cooling"
      return "Overcast — clouds prevent inversion formation"
    case "season":
      if (beneficial) {
        if (score >= 70) return "Peak ducting season (summer months)"
        if (score >= 40) return "Transitional season — moderate ducting"
        return "Weakest season for ducting at this frequency"
      } else {
        if (score >= 70) return "Peak season — cool, dry conditions"
        if (score >= 40) return "Transitional season"
        return "Summer humidity degrades propagation at this frequency"
      }
    case "wind":
      if (score >= 80) return "Calm winds — inversions preserved"
      if (score >= 55) return "Light winds — minor mixing"
      return "Strong winds — turbulent mixing destroys inversions"
    case "rain":
      if (score >= 90) return "No rain — no path attenuation"
      if (score >= 50) return "Light rain — minor attenuation"
      return "Heavy rain — significant path loss"
    case "pwat":
      if (beneficial) {
        if (score >= 80) return "High column moisture — strong refractivity"
        if (score >= 50) return "Moderate PWAT — some refractivity enhancement"
        return "Low PWAT — limited moisture for ducting"
      } else {
        if (score >= 80) return "Low column moisture — minimal absorption"
        if (score >= 50) return "Moderate PWAT"
        return "High PWAT — significant absorption at this frequency"
      }
    case "pressure":
      if (score >= 70) return "Low pressure — frontal boundaries favor ducting"
      if (score >= 45) return "Normal pressure — average conditions"
      return "High pressure — stable ridge, inversions cap at wrong altitude"
    default:
      return ""
  }
}

function rangeEstimate(score: number, detail: PointDetail): string {
  const typical = detail.typical_range_km
  if (score >= 80) return kmRangeOpenToMi(detail.extended_range_km)
  if (score >= 65) return kmRangeToMi(typical, detail.extended_range_km)
  if (score >= 50) return kmRangeToMi(typical * 0.7, typical)
  if (score >= 33) return kmRangeToMi(typical * 0.4, typical * 0.7)
  return kmCeilToMi(typical * 0.4)
}

/**
 * Flood-fill outward from a grid point, collecting all contiguous cells
 * with score >= minScore. Returns array of {lat, lon} boundary points
 * as a convex hull for drawing a polygon.
 */
function propagationReach(grid: ScoreGrid, startLat: number, startLon: number, minScore: number): LatLon[] {
  const maxKm = 300
  const snap = (v: number): number => Math.round(v / GRID_STEP) * GRID_STEP

  const snapLat = snap(startLat)
  const snapLon = snap(startLon)
  const startScore = grid.get(snapLat, snapLon)
  if (startScore == null || startScore < minScore) return []

  const cosLat = Math.cos(startLat * Math.PI / 180)
  const kmPerDegLat = 111.0
  const kmPerDegLon = 111.0 * cosLat

  const visited = new Set<number>()
  const reachable: LatLon[] = []
  const queue: [number, number][] = [[snapLat, snapLon]]
  visited.add(grid.indexOf(snapLat, snapLon))

  // BFS flood fill through grid, capped at maxKm from origin
  while (queue.length > 0) {
    const [lat, lon] = queue.shift()!

    const dLat = (lat - startLat) * kmPerDegLat
    const dLon = (lon - startLon) * kmPerDegLon
    if (Math.sqrt(dLat * dLat + dLon * dLon) > maxKm) continue

    reachable.push({ lat, lon })

    const neighbors: [number, number][] = [
      [lat + GRID_STEP, lon],
      [lat - GRID_STEP, lon],
      [lat, lon + GRID_STEP],
      [lat, lon - GRID_STEP]
    ]
    for (const [nlat, nlon] of neighbors) {
      const nidx = grid.indexOf(nlat, nlon)
      if (nidx < 0 || visited.has(nidx)) continue
      visited.add(nidx)
      const score = grid.get(nlat, nlon)
      if (score != null && score >= minScore) {
        queue.push([nlat, nlon])
      }
    }
  }

  if (reachable.length < 3) return reachable

  return convexHull(reachable)
}

function convexHull(points: LatLon[]): LatLon[] {
  // Graham scan
  points.sort((a, b) => a.lon - b.lon || a.lat - b.lat)
  const cross = (o: LatLon, a: LatLon, b: LatLon): number =>
    (a.lon - o.lon) * (b.lat - o.lat) - (a.lat - o.lat) * (b.lon - o.lon)

  const lower: LatLon[] = []
  for (const p of points) {
    while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0)
      lower.pop()
    lower.push(p)
  }
  const upper: LatLon[] = []
  for (let i = points.length - 1; i >= 0; i--) {
    const p = points[i]
    while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0)
      upper.pop()
    upper.push(p)
  }
  upper.pop()
  lower.pop()
  return lower.concat(upper)
}

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

function mobileHandleHTML(): string {
  if (!isMobile()) return ""
  return `<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>`
}

function buildLoadingHTML(detail: PointDetail): string {
  const tier = scoreTier(detail.score)
  return `<div style="position:relative;">
    ${mobileHandleHTML()}
    <div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:center;">
      <div style="display:flex;align-items:baseline;gap:8px;">
        <strong style="font-size:16px;">${detail.score}/100</strong>
        <span style="font-size:13px;font-weight:700;">${tier.label}</span>
      </div>
      <button class="detail-close-btn" style="background:rgba(0,0,0,0.2);border:none;color:#000;font-size:16px;line-height:1;width:24px;height:24px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;" title="Close">&times;</button>
    </div>
    <div style="padding:8px 10px;font-size:12px;color:#999;display:flex;align-items:center;gap:6px;">
      <span class="loading loading-spinner loading-xs"></span> Loading&hellip;
    </div>
  </div>`
}

function buildForecastSvg(forecast: ForecastPoint[]): string {
  if (!forecast || forecast.length < 2) return ""

  const marginL = 28, marginR = 6, marginT = 4, marginB = 16
  const w = isMobile() ? Math.min(260, window.innerWidth - 48) : 260, h = 72
  const plotW = w - marginL - marginR
  const plotH = h - marginT - marginB
  const n = forecast.length
  const now = new Date()

  const points: SvgPoint[] = forecast.map((f, i) => {
    const x = marginL + (i / (n - 1)) * plotW
    const y = marginT + plotH * (1 - f.score / 100)
    return { x, y, score: f.score, time: new Date(f.time) }
  })

  const polyline = points.map(p => `${p.x},${p.y}`).join(" ")

  // "Now" dot
  const nowIdx = points.reduce((best, p, i) =>
    Math.abs(p.time.getTime() - now.getTime()) < Math.abs(points[best].time.getTime() - now.getTime()) ? i : best, 0)
  const nowPt = points[nowIdx]

  // Trend (from now onward)
  const futurePoints = points.filter(p => p.time >= now)
  const firstScore = futurePoints.length > 0 ? futurePoints[0].score : points[0].score
  const lastScore = points[points.length - 1].score
  const diff = lastScore - firstScore
  let trend = ""
  if (diff > 5) trend = `<span style="color:#00ffa3;">&#9650; Improving</span>`
  else if (diff < -5) trend = `<span style="color:#ff4f4f;">&#9660; Declining</span>`
  else trend = `<span style="color:#ffe566;">&#8594; Steady</span>`

  // Best time (only future points)
  const bestPool = futurePoints.length > 0 ? futurePoints : points
  const bestPt = bestPool.reduce((best, p) => p.score > best.score ? p : best, bestPool[0])
  const bestUtc = `${bestPt.time.getUTCHours().toString().padStart(2, "0")}:00 UTC`
  const bestTier = scoreTier(bestPt.score)

  // Y-axis: score labels
  const yLabels = [0, 50, 100].map(s => {
    const y = marginT + plotH * (1 - s / 100)
    return `<text x="${marginL - 4}" y="${y + 3}" font-size="8" fill="rgba(255,255,255,0.4)" text-anchor="end">${s}</text>
      <line x1="${marginL}" y1="${y}" x2="${marginL + plotW}" y2="${y}" stroke="rgba(255,255,255,0.08)" stroke-width="1"/>`
  }).join("")

  // X-axis: time labels (first, middle, last)
  const timeIdxs = [0, Math.floor(n / 2), n - 1]
  const xLabels = timeIdxs.map(i => {
    const p = points[i]
    const label = `${p.time.getUTCHours().toString().padStart(2, "0")}:00`
    return `<text x="${p.x}" y="${h - 2}" font-size="8" fill="rgba(255,255,255,0.4)" text-anchor="middle">${label}</text>`
  }).join("")

  return `
    <div style="padding:6px 10px;border-top:1px solid rgba(255,255,255,0.15);">
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
        <span style="font-size:10px;font-weight:700;opacity:0.5;text-transform:uppercase;letter-spacing:0.5px;">Forecast (${n}h)</span>
        <span style="font-size:11px;">${trend}</span>
      </div>
      <svg width="${w}" height="${h}" style="display:block;">
        ${yLabels}
        ${xLabels}
        <polyline points="${polyline}" fill="none" stroke="#00ffa3" stroke-width="2" stroke-linejoin="round"/>
        <circle cx="${nowPt.x}" cy="${nowPt.y}" r="3" fill="#fff" stroke="#00ffa3" stroke-width="1.5"/>
        <circle cx="${bestPt.x}" cy="${bestPt.y}" r="3" fill="${bestTier.color}" stroke="#fff" stroke-width="1"/>
        <text x="${bestPt.x}" y="${bestPt.y - 6}" font-size="8" fill="${bestTier.color}" text-anchor="middle" font-weight="700">${bestPt.score}</text>
      </svg>
      <div style="font-size:11px;margin-top:3px;color:${bestTier.color};">Best at ${bestUtc} &mdash; score ${bestPt.score} (${bestTier.label})</div>
    </div>`
}

function buildPopupHTML(detail: PointDetail, viewshedLoading: boolean): string {
  const tier = scoreTier(detail.score)
  const range = rangeEstimate(detail.score, detail)
  const time = new Date(detail.valid_time).toISOString().replace("T", " ").slice(0, 16) + " UTC"

  let rows = ""
  let explanations = ""
  for (const key of FACTOR_ORDER) {
    const meta = FACTOR_META[key]
    const value = detail.factors[key] as number | undefined
    if (value === undefined) continue
    const pct = Math.round(meta.weight * 100)
    rows += `<tr>
      <td style="padding:1px 6px 1px 0;white-space:nowrap;font-size:11px;">${meta.label}</td>
      <td style="padding:1px 4px;font-family:monospace;font-size:11px;letter-spacing:-0.5px;">${factorBar(value)}</td>
      <td style="padding:1px 4px;text-align:right;font-size:11px;font-weight:600;">${value}</td>
      <td style="padding:1px 4px;font-size:10px;opacity:0.5;">(${pct}%)</td>
    </tr>`
    const expl = factorExplanation(key, value, detail)
    const eTier = scoreTier(value)
    explanations += `<div style="padding:2px 0;font-size:12px;display:flex;gap:6px;">
      <span style="color:${eTier.color};flex-shrink:0;">\u25CF</span>
      <span><strong>${meta.label}:</strong> ${expl}</span>
    </div>`
  }

  const viewshedStatus = viewshedLoading
    ? `<div style="padding:4px 10px 6px;font-size:11px;opacity:0.6;border-top:1px solid rgba(255,255,255,0.15);display:flex;align-items:center;gap:6px;"><span class="loading loading-spinner loading-xs"></span> Computing terrain coverage&hellip;</div>`
    : ""

  const mobile = isMobile()
  const minW = mobile ? "auto" : "260px"

  return `<div style="min-width:${minW};position:relative;">
    ${mobileHandleHTML()}
    <div style="background:${tier.color};color:#000;padding:6px 10px;display:flex;justify-content:space-between;align-items:center;">
      <div style="display:flex;align-items:baseline;gap:8px;">
        <strong style="font-size:16px;">${detail.score}/100</strong>
        <span style="font-size:13px;font-weight:700;">${tier.label}</span>
      </div>
      <button class="detail-close-btn" style="background:rgba(0,0,0,0.2);border:none;color:#000;font-size:16px;line-height:1;width:24px;height:24px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;" title="Close">&times;</button>
    </div>
    <div style="padding:6px 10px;">
      <div style="font-size:12px;opacity:0.7;margin-bottom:6px;">
        ${detail.band_label} &mdash; Est. range: ${range} (CW)<br>
        ${detail.lat.toFixed(3)}\u00b0N, ${Math.abs(detail.lon).toFixed(3)}\u00b0W &mdash; ${time}
      </div>
      <table style="width:100%;border-collapse:collapse;border-top:1px solid rgba(255,255,255,0.15);padding-top:4px;">
        ${rows}
      </table>
    </div>
    <div style="padding:6px 10px;border-top:1px solid rgba(255,255,255,0.15);">
      <div style="font-size:10px;font-weight:700;opacity:0.5;margin-bottom:4px;text-transform:uppercase;letter-spacing:0.5px;">Analysis</div>
      ${explanations}
    </div>
    ${detail.factors.duct_info ? buildDuctInfoHTML(detail.factors.duct_info) : ""}
    ${buildScatterBlock(detail.rain_scatter)}
    ${detail.forecast ? buildForecastSvg(detail.forecast) : ""}
    ${viewshedStatus}
  </div>`
}

function buildScatterBlock(scatter: RainScatter | "pending" | undefined): string {
  if (scatter === "pending") {
    return `
      <div style="padding:4px 10px 6px;border-top:1px solid rgba(255,255,255,0.15);">
        <div style="font-size:10px;font-weight:700;opacity:0.5;margin-bottom:3px;text-transform:uppercase;letter-spacing:0.5px;">Rain Scatter</div>
        <div style="font-size:11px;opacity:0.6;">Checking NEXRAD\u2026</div>
      </div>`
  }
  if (scatter && scatter.cells.length > 0) return buildScatterHTML(scatter)
  return ""
}

function buildScatterHTML(scatter: RainScatter): string {
  const cls = scatter.classification
  const cells = scatter.cells
  const clsColors: Record<string, string> = { excellent: "#059669", good: "#0d9488", marginal: "#ca8a04", none: "#666" }
  const clsColor = clsColors[cls] || "#666"
  const clsLabel = cls.charAt(0).toUpperCase() + cls.slice(1)

  const top3 = cells.slice(0, 3)
  const rows = top3.map(c => {
    const dir = bearingLabel(c.bearing)
    return `<div style="font-size:11px;padding:1px 0;">
      ${c.dbz} dBZ at ${formatDistanceKm(c.distance_km)} ${dir} (${c.scatter_db} dB)
    </div>`
  }).join("")

  return `
    <div style="padding:4px 10px 6px;border-top:1px solid rgba(255,255,255,0.15);">
      <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;">
        <span style="font-size:10px;font-weight:700;opacity:0.5;text-transform:uppercase;letter-spacing:0.5px;">Rain Scatter</span>
        <span style="font-size:11px;font-weight:700;color:${clsColor};">${clsLabel}</span>
      </div>
      ${rows}
      ${cells.length > 3 ? `<div style="font-size:10px;opacity:0.5;">+${cells.length - 3} more cells</div>` : ""}
    </div>`
}

function bearingLabel(deg: number): string {
  const dirs = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
  return dirs[Math.round(deg / 22.5) % 16]
}

function buildDuctInfoHTML(info: DuctInfo): string {
  const layers = info.ducts || []

  let layerRows = ""
  if (layers.length > 0) {
    layerRows = layers.map((d, i) => {
      const baseFt = Math.round(d.base_m * 3.281)
      const topFt = Math.round(d.top_m * 3.281)
      const freq = d.min_freq_ghz != null ? `\u2265${d.min_freq_ghz.toFixed(1)} GHz` : ""
      return `<div style="font-size:11px;padding:1px 0;">
        Layer ${i + 1}: <strong>${baseFt}\u2013${topFt} ft</strong> (${d.thickness_m} m) ${freq}
      </div>`
    }).join("")
  }
  return `
    <div style="padding:4px 10px 6px;border-top:1px solid rgba(255,255,255,0.15);">
      <div style="font-size:10px;font-weight:700;opacity:0.5;margin-bottom:3px;text-transform:uppercase;letter-spacing:0.5px;">Ducting &mdash; ${info.duct_count} layer${info.duct_count !== 1 ? "s" : ""}</div>
      ${layerRows}
    </div>`
}

// --- Hook ---

export const PropagationMap: Record<string, unknown> & {
  mounted(this: PropagationMapHook): void
  showDetailPanel(this: PropagationMapHook): void
  hideDetailPanel(this: PropagationMapHook): void
  redrawReachPolygon(this: PropagationMapHook): void
  renderScores(this: PropagationMapHook, scores: ScorePoint[]): void
  interpolateScore(this: PropagationMapHook, lat: number, lon: number, step: number): number | null
  scoreColorRGB(this: PropagationMapHook, score: number): RGB
  lookupPoint(this: PropagationMapHook, lat: number, lon: number): (ScorePoint & BandInfo) | null
  drawScatterMarkers(this: PropagationMapHook, scatter: RainScatter): void
  renderTimeline(this: PropagationMapHook): void
  selectTimelineTime(this: PropagationMapHook, time: string): void
  startPlayback(this: PropagationMapHook): void
  stopPlayback(this: PropagationMapHook): void
  sendBounds(this: PropagationMapHook): void
  reconnected(this: PropagationMapHook): void
  destroyed(this: PropagationMapHook): void
} = {
  mounted(this: PropagationMapHook) {
    const centerLat = parseFloat(this.el.dataset.centerLat!)
    const centerLon = parseFloat(this.el.dataset.centerLon!)
    const zoom = parseInt(this.el.dataset.zoom!, 10)

    const center: [number, number] =
      Number.isFinite(centerLat) && Number.isFinite(centerLon)
        ? [centerLat, centerLon]
        : [32.897, -97.038]

    this.map = L.map(this.el, {
      center,
      zoom: Number.isFinite(zoom) ? zoom : 7,
      minZoom: 4,
      maxZoom: 13
    })

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

    this.scoreOverlay = null
    this.scores = []
    this.forecastCache = new Map()
    this.playbackTimer = null

    this.colorScale = [
      { min: 80, r: 0, g: 255, b: 163 },
      { min: 65, r: 125, g: 255, b: 212 },
      { min: 50, r: 255, g: 229, b: 102 },
      { min: 33, r: 255, g: 144, b: 68 },
      { min: 0,  r: 255, g: 79,  b: 79 }
    ]

    // Render pre-loaded scores immediately (no round-trip needed)
    const initialScores: ScorePoint[] = JSON.parse(this.el.dataset.scores || "[]")
    if (initialScores.length > 0) {
      this.renderScores(initialScores)
    }

    this.handleEvent("update_scores", ({ scores }: { scores: ScorePoint[] }) => {
      topbar.hide()
      this.renderScores(scores)
      if (this.selectedTime) this.forecastCache.set(this.selectedTime, scores)

      // Reach polygon is a pure function of scoreGrid + clickedLatLng, so we
      // can refresh it immediately client-side without waiting on the server
      // round-trip. Handles the case where the new band has no reach at all
      // (hull empty → stale polygon from the old band is cleared).
      this.redrawReachPolygon()

      // If the detail panel is open, refresh its factors/forecast for the
      // new band via the server. The polygon is already up-to-date above.
      if (this.clickedLatLng && this.detailPanel && this.detailPanel.style.display !== "none") {
        this.pushEvent("point_detail", { lat: this.clickedLatLng[0], lon: this.clickedLatLng[1] })
      }
    })

    // Preloaded forecast hours arrive after mount/band change — stash them so
    // future timeline scrubs can render instantly from the local cache.
    this.handleEvent("preload_forecast", ({ hours }: { hours: { time: string; scores: ScorePoint[] }[] }) => {
      for (const h of hours) {
        this.forecastCache.set(h.time, h.scores)
      }
    })

    this.bandInfo = JSON.parse(this.el.dataset.bandInfo || "{}")
    this.rangeCircles = L.layerGroup().addTo(this.map)

    this.handleEvent("update_band_info", ({ band_info }: { band_info: BandInfo }) => {
      this.bandInfo = band_info

      // Viewshed depends on freq + score-derived range, so recompute it when
      // the band changes or antenna height moves if a point is still selected.
      // Also re-request point_detail so the popup's range-estimate line picks
      // up the updated typical/extended/exceptional ranges. update_scores
      // (on band change) already refreshes the polygon/heatmap; for
      // antenna-height changes the polygon is height-independent today.
      if (this.clickedLatLng && this.detailPanel && this.detailPanel.style.display !== "none") {
        this.viewshedLoading = true
        this.pushEvent("compute_viewshed", {
          lat: this.clickedLatLng[0],
          lon: this.clickedLatLng[1]
        })
        this.pushEvent("point_detail", {
          lat: this.clickedLatLng[0],
          lon: this.clickedLatLng[1]
        })
      }
    })

    // Prevent control panel / sidebar from eating map clicks/scrolls
    const controls = document.getElementById("map-controls")
    if (controls) {
      L.DomEvent.disableClickPropagation(controls)
      L.DomEvent.disableScrollPropagation(controls)
    }
    const sidebar = document.getElementById("sidebar")
    if (sidebar) {
      L.DomEvent.disableClickPropagation(sidebar)
      L.DomEvent.disableScrollPropagation(sidebar)
    }

    // Invalidate map size when sidebar is toggled
    window.addEventListener("sidebar-toggle", () => {
      setTimeout(() => this.map.invalidateSize(), 50)
    })

    // Grid overlay layer
    this.gridLayer = L.layerGroup()
    this.gridVisible = false

    // Listen for grid toggle from LiveView
    this.handleEvent("toggle_grid", ({ visible }: { visible: boolean }) => {
      this.gridVisible = visible
      localStorage.setItem("propagationMap.gridVisible", String(visible))
      if (visible) {
        this.gridLayer.addTo(this.map)
        updateGridOverlay(this.map, this.gridLayer)
      } else {
        this.gridLayer.remove()
      }
    })

    if (localStorage.getItem("propagationMap.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("propagationMap.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("propagationMap.radarVisible") === "true") {
      this.pushEvent("toggle_radar", {})
    }

    // Click on map to request viewshed + factor detail from server
    this.map.on("click", (e: L.LeafletMouseEvent) => {
      this.rangeCircles.clearLayers()
      this.viewshedLoading = true
      this.lastDetail = null

      // Remember click location for the center marker
      this.clickedLatLng = [e.latlng.lat, e.latlng.lng]
      localStorage.setItem("propagationMap.clickedPoint", JSON.stringify(this.clickedLatLng))

      // Show a temporary marker while computing
      L.circleMarker(this.clickedLatLng, {
        radius: 6,
        color: "#fff",
        weight: 2,
        fillColor: "#888",
        fillOpacity: 0.8,
        interactive: false
      }).addTo(this.rangeCircles)

      // Always request terrain viewshed
      this.pushEvent("compute_viewshed", { lat: e.latlng.lat, lon: e.latlng.lng })

      // Show panel immediately with whatever we have client-side
      const basic = this.lookupPoint(e.latlng.lat, e.latlng.lng)
      if (basic && this.detailPanel) {
        const merged = { ...basic, ...this.bandInfo, factors: null } as unknown as PointDetail
        this.detailPanel.innerHTML = buildLoadingHTML(merged)
        this.showDetailPanel()
        this.pushEvent("point_detail", { lat: e.latlng.lat, lon: e.latlng.lng })
      }
    })

    // Detail panel — floating overlay on the map
    this.detailPanel = document.getElementById("detail-panel")
    this.viewshedLoading = false

    if (this.detailPanel) {
      L.DomEvent.disableClickPropagation(this.detailPanel)
      L.DomEvent.disableScrollPropagation(this.detailPanel)

      // Close button event delegation
      this.detailPanel.addEventListener("click", (e: MouseEvent) => {
        if ((e.target as HTMLElement).closest(".detail-close-btn")) {
          this.hideDetailPanel()
        }
      })
    }
    this.lastDetail = null

    // Restore a previously-clicked point across page navigation: replay the
    // click side-effects so the user lands back on the same detail panel
    // they had open, with fresh server-side factors + viewshed.
    const storedPoint = localStorage.getItem("propagationMap.clickedPoint")
    if (storedPoint) {
      try {
        const parsed = JSON.parse(storedPoint)
        if (Array.isArray(parsed) && typeof parsed[0] === "number" && typeof parsed[1] === "number") {
          const [lat, lon] = parsed as [number, number]
          this.clickedLatLng = [lat, lon]
          this.viewshedLoading = true
          L.circleMarker([lat, lon], {
            radius: 6, color: "#fff", weight: 2,
            fillColor: "#888", fillOpacity: 0.8, interactive: false
          }).addTo(this.rangeCircles)
          this.pushEvent("compute_viewshed", { lat, lon })
          const basic = this.lookupPoint(lat, lon)
          if (basic && this.detailPanel) {
            const merged = { ...basic, ...this.bandInfo, factors: null } as unknown as PointDetail
            this.detailPanel.innerHTML = buildLoadingHTML(merged)
            this.showDetailPanel()
          }
          this.pushEvent("point_detail", { lat, lon })
        }
      } catch {
        localStorage.removeItem("propagationMap.clickedPoint")
      }
    }

    this.handleEvent("rain_scatter_update", ({ rain_scatter }: { lat: number; lon: number; rain_scatter: RainScatter }) => {
      if (!this.lastDetail || !this.detailPanel) return
      this.lastDetail.rain_scatter = rain_scatter
      this.detailPanel.innerHTML = buildPopupHTML(this.lastDetail, this.viewshedLoading)
      if (rain_scatter && rain_scatter.cells && rain_scatter.cells.length > 0) {
        this.drawScatterMarkers(rain_scatter)
      }
    })

    this.handleEvent("point_detail", (detail: PointDetail) => {
      if (detail && this.detailPanel) {
        const merged: PointDetail = { ...detail, ...this.bandInfo }
        this.lastDetail = merged
        this.detailPanel.innerHTML = buildPopupHTML(merged, this.viewshedLoading)
        this.showDetailPanel()

        // Draw rain scatter cells on the map
        if (this.scatterMarkers) {
          this.scatterMarkers.clearLayers()
        } else {
          this.scatterMarkers = L.layerGroup().addTo(this.map)
        }
        if (detail.rain_scatter && detail.rain_scatter !== "pending" && detail.rain_scatter.cells.length > 0) {
          this.drawScatterMarkers(detail.rain_scatter)
        }

        // Draw propagation reach polygon from contiguous good cells.
        // Shared helper handles cleanup + empty-hull case so the old
        // polygon doesn't stick around when the new band has no reach.
        this.redrawReachPolygon()
      }
    })

    this.handleEvent("viewshed_result", ({ origin, points }: ViewshedResult) => {
      this.rangeCircles.clearLayers()
      this.viewshedLoading = false

      // Re-render panel without loading indicator
      if (this.lastDetail && this.detailPanel) {
        this.detailPanel.innerHTML = buildPopupHTML(this.lastDetail, false)
      }

      if (points && points.length > 0) {
        const latlngs = points.map(p => [p.lat, p.lon] as [number, number])

        L.polygon(latlngs, {
          color: "#222",
          weight: 2,
          opacity: 0.8,
          fillColor: "#000",
          fillOpacity: 0.12,
          interactive: false,
          smoothFactor: 1
        }).addTo(this.rangeCircles)
      }

      // Always redraw center marker at clicked location
      const loc: [number, number] = this.clickedLatLng || [origin.lat, origin.lon]
      const basic = this.lookupPoint(loc[0], loc[1])
      const markerColor = basic ? scoreTier(basic.score).color : "#888"

      L.circleMarker(loc, {
        radius: 6,
        color: "#fff",
        weight: 2,
        fillColor: markerColor,
        fillOpacity: 1,
        interactive: false
      }).addTo(this.rangeCircles)
    })


    // --- Forecast timeline ---
    this.timelineEl = document.getElementById("forecast-timeline")
    this.timelineData = JSON.parse(this.el.dataset.validTimes || "[]")
    this.selectedTime = this.el.dataset.selectedTime || null
    if (this.timelineData.length > 1) {
      this.renderTimeline()
    }

    this.handleEvent("update_timeline", ({ times, selected }: { times: TimelineEntry[]; selected: string }) => {
      // Band change or new forecast hour arrived — whatever playback
      // was iterating through no longer matches the new set, so drop
      // the timer before we rewrite timelineData under it.
      if (this.playbackTimer !== null) {
        clearInterval(this.playbackTimer)
        this.playbackTimer = null
      }
      this.timelineData = times
      this.selectedTime = selected
      this.renderTimeline()
    })

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

    // Escape key closes detail panel and range circles
    document.addEventListener("keydown", (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        this.hideDetailPanel()
      }
    })

    this.el.addEventListener("show-loading", () => topbar.show(300))

    requestAnimationFrame(() => this.sendBounds())
    // Re-check bounds after layout settles (sidebar, fonts, etc.)
    setTimeout(() => { this.map.invalidateSize(); this.sendBounds() }, 500)

    // When the tab is hidden, Leaflet may create or drop score tiles while the
    // server-side assigns (bounds, scores) are gone. On return, the retained
    // scoreGrid covers the old viewport and any freshly-created tiles outside
    // it paint blank. Re-fetch bounds so update_scores repaints all tiles with
    // fresh data. reconnected() handles the socket-drop case on top of this.
    this.visibilityHandler = () => {
      if (document.visibilityState === "visible") {
        this.map.invalidateSize()
        this.sendBounds()
      }
    }
    document.addEventListener("visibilitychange", this.visibilityHandler)
    this.map.on("moveend", () => {
      this.sendBounds()
      if (this.gridVisible) {
        updateGridOverlay(this.map, this.gridLayer)
      }
    })

    // Legend lives in the top-right on every viewport so the
    // forecast timeline at the bottom can stretch to full width
    // without the legend covering the later hours.
    const legend = L.control({ position: "topright" })
    legend.onAdd = () => {
      const div = L.DomUtil.create("div")
      const mobile = isMobile()
      const rows: [string, string, string][] = [
        ["#00ffa3", "Excellent", "80+"],
        ["#7dffd4", "Good", "65"],
        ["#ffe566", "Marginal", "50"],
        ["#ff9044", "Poor", "33"],
        ["#ff4f4f", "Negligible", "0"]
      ]
      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 = rows.map(([c, , score]) =>
          `<span style="background:${c};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>${score}`
        ).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;'>Propagation</strong><br>" +
          rows.map(([c, label, score]) =>
            `<span style="background:${c};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;">${label} (${score === "80+" ? "80-100" : score === "0" ? "0-32" : score + "-" + (parseInt(score) === 65 ? "79" : parseInt(score) === 50 ? "64" : "49")})</span>`
          ).join("<br>")
      }
      return div
    }
    legend.addTo(this.map)
  },

  showDetailPanel(this: PropagationMapHook) {
    if (!this.detailPanel) return
    this.detailPanel.style.display = "block"
  },

  hideDetailPanel(this: PropagationMapHook) {
    if (this.detailPanel) {
      this.detailPanel.style.display = "none"
      this.detailPanel.innerHTML = ""
    }
    this.rangeCircles.clearLayers()
    if (this.scatterMarkers) this.scatterMarkers.clearLayers()
    this.clickedLatLng = null
    this.reachPolygon = null
    this.lastDetail = null
    localStorage.removeItem("propagationMap.clickedPoint")
  },

  // Redraws the propagation reach polygon around the currently-clicked
  // point. Safe to call repeatedly — always removes the previous polygon
  // first so we don't leave a stale shape from an earlier band on the map
  // when the new band's reach is too small to hull. The polygon is a pure
  // function of this.scoreGrid and this.clickedLatLng, so it can be
  // refreshed on update_scores without waiting for the server's detail.
  redrawReachPolygon(this: PropagationMapHook) {
    if (this.reachPolygon) {
      this.rangeCircles.removeLayer(this.reachPolygon)
      this.reachPolygon = null
    }

    if (!this.scoreGrid || !this.clickedLatLng) return

    const minScore = 50 // MARGINAL — minimum useful propagation
    const hull = propagationReach(
      this.scoreGrid, this.clickedLatLng[0], this.clickedLatLng[1], minScore
    )
    if (hull.length < 3) return

    const basic = this.lookupPoint(this.clickedLatLng[0], this.clickedLatLng[1])
    const centerScore = basic ? basic.score : 50
    const tier = scoreTier(centerScore)

    this.reachPolygon = L.polygon(
      hull.map(p => [p.lat, p.lon] as [number, number]),
      {
        color: tier.color,
        weight: 2,
        opacity: 0.6,
        fillColor: tier.color,
        fillOpacity: 0.08,
        interactive: false,
        smoothFactor: 1.5,
        dashArray: "6 4"
      }
    ).addTo(this.rangeCircles)
  },

  renderScores(this: PropagationMapHook, scores: ScorePoint[]) {
    this.scores = scores

    if (scores.length === 0) {
      if (this.scoreOverlay) {
        this.map.removeLayer(this.scoreOverlay)
        this.scoreOverlay = null
      }
      return
    }

    if (!this.scoreGrid) {
      this.scoreGrid = new ScoreGrid()
    } else {
      this.scoreGrid.reset()
    }
    scores.forEach((s) => this.scoreGrid.put(s.lat, s.lon, s.score))

    if (!this.colorCache) {
      this.colorCache = new Array<string>(101)
      for (let i = 0; i <= 100; i++) {
        const c = this.scoreColorRGB(i)
        this.colorCache[i] = `rgba(${c.r},${c.g},${c.b},0.55)`
      }
    }

    if (this.scoreOverlay) {
      // Repaint each existing tile canvas in place. Leaflet's redraw() calls
      // _removeAllTiles() which briefly leaves the overlay empty — visible as
      // a flash on every update_scores event (mount sendBounds, invalidateSize
      // moveend, preload_forecast, etc.). Writing fresh pixels onto existing
      // canvases keeps the DOM stable and eliminates the blink.
      const tiles = (this.scoreOverlay as unknown as { _tiles: Record<string, { el: HTMLCanvasElement; coords: L.Coords }> })._tiles || {}
      for (const key in tiles) {
        const t = tiles[key]
        if (t && t.el && t.coords) {
          this.drawTileCanvas(t.el, t.coords, this.scoreOverlay)
        }
      }
      return
    }

    // First render — build the GridLayer once and reuse it for every update.
    // eslint-disable-next-line @typescript-eslint/no-this-alias
    const self = this
    this.scoreOverlay = new (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
        self.drawTileCanvas(tile, coords, this)
        return tile
      }
    }))({ opacity: 1.0 }) as L.GridLayer
    this.scoreOverlay.addTo(this.map)
  },

  drawTileCanvas(this: PropagationMapHook, canvas: HTMLCanvasElement, coords: L.Coords, layer: L.GridLayer) {
    const ctx = canvas.getContext("2d")!
    const size = layer.getTileSize()
    ctx.clearRect(0, 0, size.x, size.y)

    const step = 0.125
    const nwPoint = coords.scaleBy(size)
    const map = (layer as unknown as { _map: L.Map })._map
    if (!map) return
    const nw = map.unproject(nwPoint, coords.z) as L.LatLng
    const se = 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)

    let lastColor: string | null = null
    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 score = this.interpolateScore(lat, lon, step)
        if (score !== null) {
          const color = this.colorCache[Math.max(0, Math.min(100, Math.round(score)))]
          if (color !== lastColor) {
            ctx.fillStyle = color
            lastColor = color
          }
          ctx.fillRect(px, py, cellPxX, cellPxY)
        }
      }
    }
  },

  interpolateScore(this: PropagationMapHook, lat: number, lon: number, _step: number): number | null {
    // Snapped cell — hot path, zero allocations
    const latIdxR = Math.round((lat - GRID_LAT_MIN) / GRID_STEP)
    const lonIdxR = Math.round((lon - GRID_LON_MIN) / GRID_STEP)
    const snapped = this.scoreGrid.getByIdx(latIdxR, lonIdxR)
    if (snapped !== null) return snapped

    // Bilinear interpolation across 4 neighbors
    const latFloor = Math.floor((lat - GRID_LAT_MIN) / GRID_STEP)
    const lonFloor = Math.floor((lon - GRID_LON_MIN) / GRID_STEP)
    const tLat = ((lat - GRID_LAT_MIN) / GRID_STEP) - latFloor
    const tLon = ((lon - GRID_LON_MIN) / GRID_STEP) - lonFloor

    const s00 = this.scoreGrid.getByIdx(latFloor, lonFloor)
    const s10 = this.scoreGrid.getByIdx(latFloor + 1, lonFloor)
    const s01 = this.scoreGrid.getByIdx(latFloor, lonFloor + 1)
    const s11 = this.scoreGrid.getByIdx(latFloor + 1, lonFloor + 1)

    if (s00 !== null && s10 !== null && s01 !== null && s11 !== null) {
      return Math.round(
        s00 * (1 - tLat) * (1 - tLon) + s10 * tLat * (1 - tLon) +
        s01 * (1 - tLat) * tLon + s11 * tLat * tLon
      )
    }

    // Partial corners — average whatever we have (minimum 2 for a reasonable value)
    let sum = 0
    let count = 0
    if (s00 !== null) { sum += s00; count++ }
    if (s10 !== null) { sum += s10; count++ }
    if (s01 !== null) { sum += s01; count++ }
    if (s11 !== null) { sum += s11; count++ }
    if (count < 2) return null
    return Math.round(sum / count)
  },

  scoreColorRGB(this: PropagationMapHook, score: number): RGB {
    for (let i = 0; i < this.colorScale.length - 1; i++) {
      const upper = this.colorScale[i]
      const lower = this.colorScale[i + 1]
      if (score >= lower.min) {
        const range = upper.min - lower.min
        const t = Math.min(1, (score - lower.min) / range)
        return {
          r: Math.round(lower.r + (upper.r - lower.r) * t),
          g: Math.round(lower.g + (upper.g - lower.g) * t),
          b: Math.round(lower.b + (upper.b - lower.b) * t)
        }
      }
    }
    const last = this.colorScale[this.colorScale.length - 1]
    return { r: last.r, g: last.g, b: last.b }
  },

  lookupPoint(this: PropagationMapHook, lat: number, lon: number): (ScorePoint & BandInfo) | null {
    if (!this.scoreGrid) return null
    const rlat = Math.round(lat / GRID_STEP) * GRID_STEP
    const rlon = Math.round(lon / GRID_STEP) * GRID_STEP
    const score = this.scoreGrid.get(rlat, rlon)
    if (score == null) return null
    return { lat: rlat, lon: rlon, score, ...this.bandInfo }
  },

  drawScatterMarkers(this: PropagationMapHook, scatter: RainScatter) {
    if (!this.scatterMarkers) this.scatterMarkers = L.layerGroup().addTo(this.map)
    this.scatterMarkers.clearLayers()
    for (const c of scatter.cells) {
      const opacity = Math.min(0.9, Math.max(0.3, (c.scatter_db + 30) / 30))
      const color = c.dbz >= 45 ? "#dc2626" : c.dbz >= 35 ? "#ea580c" : "#ca8a04"
      const marker = L.circleMarker([c.lat, c.lon], {
        radius: Math.min(12, Math.max(4, c.dbz / 5)),
        color,
        fillColor: color,
        fillOpacity: opacity * 0.5,
        weight: 1.5,
        opacity,
        interactive: false
      })
      this.scatterMarkers.addLayer(marker)
    }
  },

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

    // Single time: show as a simple data timestamp, no buttons
    if (this.timelineData.length === 1) {
      const dt = new Date(this.timelineData[0].time)
      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
    }

    // Hide timeline if all times are within 1 hour (no real forecast spread)
    const firstDt = new Date(this.timelineData[0].time)
    const lastDt = new Date(this.timelineData[this.timelineData.length - 1].time)
    if (lastDt.getTime() - firstDt.getTime() < 3600000) {
      const dt = firstDt
      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()
    const items: TimelineItem[] = this.timelineData.map((t, idx) => {
      const dt = new Date(t.time)
      const isSelected = t.time === this.selectedTime
      const isPast = dt.getTime() < now.getTime() - 1800000 // 30min grace
      return { ...t, dt, isSelected, isPast, offsetH: 0, idx, label: "" }
    })

    // "Now" = the latest forecast hour at or before wall-clock. Picking
    // by absolute distance rounds up past the half-hour and labels a
    // future slot "Now" (e.g. at 18:35 UTC, 19:00 is 25 min away vs.
    // 18:00 at 35 min). When every available forecast hour is in the
    // future (e.g. a pipeline gap where f00-f05 got discarded and only
    // f06+ made it onto disk), leave everything as "+Nh" labels rather
    // than lying with a "Now" tag on a future slot.
    const nowMs = now.getTime()
    const pastOrNow = items.filter(t => t.dt.getTime() <= nowMs)
    const nowIdx = pastOrNow.length > 0
      ? items.indexOf(pastOrNow.reduce((latest, t) => t.dt.getTime() > latest.dt.getTime() ? t : latest))
      : -1

    // 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 = nowIdx >= 0 ? items[nowIdx].dt.getTime() : nowMs
    items.forEach((t, i) => {
      const diff = Math.round((t.dt.getTime() - anchorMs) / 3600000)
      t.offsetH = diff

      if (i === nowIdx) {
        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 ? "#00ffa3" : (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 #00ffa3" : "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" : "Propagation Forecast"
    const isPlaying = this.playbackTimer !== null
    const playBg = isPlaying ? "#00ffa3" : "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>`

    // Attach click handlers
    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: PropagationMapHook, time: string) {
    this.selectedTime = time
    this.renderTimeline()

    const cached = this.forecastCache.get(time)
    if (cached) {
      // Fast path — render instantly from preloaded cache, just inform
      // the server of the new selected time for state tracking.
      this.renderScores(cached)
      this.redrawReachPolygon()
      this.pushEvent("set_selected_time", { time })
    } else {
      // Cache miss — fall back to server roundtrip via the existing
      // update_scores path (which also redraws the polygon).
      topbar.show()
      this.pushEvent("select_time", { time })
    }
  },

  startPlayback(this: PropagationMapHook) {
    // Restarting re-seeds at "now" so repeated clicks don't resume
    // mid-loop — the user's mental model is "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, t, i) => {
      const d = Math.abs(new Date(t.time).getTime() - now)
      const bestD = Math.abs(new Date(this.timelineData[best].time).getTime() - now)
      return d < bestD ? i : best
    }, 0)

    // Iterate through "now" + every later forecast hour on disk, then
    // loop back. Past forecast hours (items before nowIdx) are excluded
    // so the animation only steps forward in time.
    const playable = this.timelineData.slice(nowIdx)
    if (playable.length < 2) return

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

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

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

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

    // "Stop" returns the map to "now" — the timeline entry closest to
    // the current wall clock.
    const now = Date.now()
    const nowIdx = this.timelineData.reduce((best, t, i) => {
      const d = Math.abs(new Date(t.time).getTime() - now)
      const bestD = Math.abs(new Date(this.timelineData[best].time).getTime() - now)
      return d < bestD ? i : best
    }, 0)
    this.selectTimelineTime(this.timelineData[nowIdx].time)
  },

  sendBounds(this: PropagationMapHook) {
    topbar.show()
    // Bounds changed — preloaded forecast hours are now for stale viewport.
    // Drop them so timeline scrubs re-fetch for the new viewport.
    this.forecastCache.clear()
    // Pad bounds by ~0.5° so Leaflet edge tiles always have data to render
    const b = this.map.getBounds().pad(0.1)
    const c = this.map.getCenter()
    this.pushEvent("map_bounds", {
      south: b.getSouth(),
      north: b.getNorth(),
      west: b.getWest(),
      east: b.getEast(),
      center_lat: c.lat,
      center_lon: c.lng,
      zoom: this.map.getZoom()
    })
  },

  reconnected(this: PropagationMapHook) {
    // Server assigns (bounds, scores) reset on reconnect. Re-send bounds so
    // update_scores refreshes the client scoreGrid and repaints all tiles,
    // fixing partial/blank overlay after the tab comes back.
    if (this.map) {
      this.map.invalidateSize()
      this.sendBounds()
    }
  },

  destroyed(this: PropagationMapHook) {
    if (this.playbackTimer !== null) {
      clearInterval(this.playbackTimer)
      this.playbackTimer = null
    }
    if (this.visibilityHandler) {
      document.removeEventListener("visibilitychange", this.visibilityHandler)
      this.visibilityHandler = null
    }
    if (this.map) this.map.remove()
  }
}
