import type { ViewHook } from "phoenix_live_view"
import { updateGridOverlay } from "./maidenhead_grid"

interface Station {
  callsign: string
  lat: number
  lon: number
  selected: boolean
}

interface Cell {
  lat: number
  lon: number
  score: number
  tier_color: string
}

interface RoverMapHook extends ViewHook {
  map: L.Map
  osmLayer: L.TileLayer
  topoLayer: L.TileLayer | null
  satelliteLayer: L.TileLayer
  hillshadeLayer: L.TileLayer
  layersControl: L.Control.Layers
  stations: Station[]
  stationMarkers: L.LayerGroup
  homeMarker: L.CircleMarker | null
  driveCircle: L.Circle | null
  qualityLayer: L.GridLayer | null
  selectedCandidateMarker: L.CircleMarker | null
  candidatePathLayer: L.LayerGroup
  candidateBuildingsLayer: L.LayerGroup
  gridLayer: L.LayerGroup
  gridVisible: boolean
  cellLookup: Map<string, Cell>
  homeLat: number
  homeLon: number
  driveRadiusKm: number
  visibilityHandler: (() => void) | null
  driveRadiusListener: ((e: Event) => void) | null

  renderStations(this: RoverMapHook): void
  buildQualityLayer(this: RoverMapHook): L.GridLayer
  setDriveRadius(this: RoverMapHook, km: number): void
}

const TIER_RGB: Record<string, [number, number, number]> = {
  "#16a34a": [22, 163, 74],   // Excellent
  "#eab308": [234, 179, 8],   // Good
  "#f97316": [249, 115, 22]   // Marginal
}

const TIER_ORDER = ["#16a34a", "#eab308", "#f97316"]

function kmToM(km: number): number {
  return km * 1000
}

function cellKey(lat: number, lon: number): string {
  return `${lat.toFixed(3)},${lon.toFixed(3)}`
}

function tierForScore(score: number): string | null {
  if (score >= 10) return "#16a34a"
  if (score >= 3) return "#eab308"
  if (score >= 0) return "#f97316"
  return null
}

function buildStationMarker(station: Station): L.CircleMarker {
  return L.circleMarker([station.lat, station.lon], {
    radius: 4,
    color: "#fff",
    weight: 1.5,
    fillColor: station.selected ? "#0ea5e9" : "#64748b",
    fillOpacity: 0.95,
    interactive: true
  }).bindTooltip(station.callsign, {
    permanent: true,
    direction: "right",
    offset: [6, 0],
    className: "rover-station-label"
  })
}

export const RoverMap: Partial<RoverMapHook> = {
  mounted(this: RoverMapHook) {
    this.stations = []
    this.stationMarkers = L.layerGroup()
    this.homeMarker = null
    this.driveCircle = null
    this.qualityLayer = null
    this.selectedCandidateMarker = null
    this.candidatePathLayer = L.layerGroup()
    this.candidateBuildingsLayer = L.layerGroup()
    this.gridLayer = L.layerGroup()
    this.gridVisible = true
    this.cellLookup = new Map()
    this.topoLayer = null

    const ds = this.el.dataset
    this.homeLat = parseFloat(ds.homeLat || "32.5")
    this.homeLon = parseFloat(ds.homeLon || "-97.5")
    this.driveRadiusKm = parseFloat(ds.driveRadiusKm || "100")

    const map = L.map(this.el, {
      center: [this.homeLat, this.homeLon],
      zoom: 8,
      zoomControl: true,
      attributionControl: false
    })
    this.map = map

    this.osmLayer = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
      maxZoom: 19
    })
    this.topoLayer = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", {
      maxZoom: 17
    })
    this.satelliteLayer = L.tileLayer(
      "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
      { maxZoom: 19 }
    )
    this.hillshadeLayer = L.tileLayer(
      "https://services.arcgisonline.com/ArcGIS/rest/services/Elevation/World_Hillshade/MapServer/tile/{z}/{y}/{x}",
      { maxZoom: 16, opacity: 0.45 }
    )

    this.osmLayer.addTo(map)
    this.hillshadeLayer.addTo(map)

    this.layersControl = L.control.layers(
      { "OSM": this.osmLayer, "Topo": this.topoLayer, "Satellite": this.satelliteLayer },
      { "Hillshade": this.hillshadeLayer, "Grid squares": this.gridLayer },
      { position: "topright" }
    ).addTo(map)

    map.on("overlayadd", (e: L.LayersControlEvent) => {
      if (e.layer === this.gridLayer) {
        this.gridVisible = true
        updateGridOverlay(this.map, this.gridLayer)
      }
    })
    map.on("overlayremove", (e: L.LayersControlEvent) => {
      if (e.layer === this.gridLayer) this.gridVisible = false
    })
    map.on("moveend", () => {
      if (this.gridVisible) updateGridOverlay(this.map, this.gridLayer)
    })

    // Silently fall back if OpenTopoMap is unreachable.
    this.topoLayer.on("tileerror", () => {
      if (!this.topoLayer) return
      if (map.hasLayer(this.topoLayer)) {
        map.removeLayer(this.topoLayer)
        this.osmLayer.addTo(map)
      }
      this.layersControl.removeLayer(this.topoLayer)
      this.topoLayer = null
    })

    this.stationMarkers.addTo(map)

    this.homeMarker = L.circleMarker([this.homeLat, this.homeLon], {
      radius: 7,
      color: "#ffffff",
      weight: 2,
      fillColor: "#0ea5e9",
      fillOpacity: 0.95,
      pane: "markerPane"
    }).addTo(map)

    this.homeMarker.bindTooltip("Home", {
      permanent: true,
      direction: "top",
      offset: [0, -10],
      className: "rover-home-label"
    })

    this.driveCircle = L.circle([this.homeLat, this.homeLon], {
      radius: kmToM(this.driveRadiusKm),
      color: "#0ea5e9",
      weight: 1.5,
      dashArray: "8 6",
      fill: false
    }).addTo(map)

    // Frame the initial view around the drive-radius circle so the
    // user starts zoomed in at the configured max-distance level.
    map.fitBounds(this.driveCircle.getBounds(), { padding: [20, 20] })

    this.candidatePathLayer.addTo(map)
    this.candidateBuildingsLayer.addTo(map)
    this.gridLayer.addTo(map)
    updateGridOverlay(map, this.gridLayer)

    L.control.scale({ metric: false, imperial: true, position: "bottomleft" }).addTo(map)

    // Render initial stations from data attribute
    try {
      const initial: Station[] = JSON.parse(this.el.dataset.stations || "[]")
      this.stations = initial
      this.renderStations()
    } catch {
      this.stations = []
    }

    this.visibilityHandler = () => {
      if (document.visibilityState === "visible") {
        this.map.invalidateSize()
      }
    }
    document.addEventListener("visibilitychange", this.visibilityHandler)

    this.driveRadiusListener = (e: Event) => {
      const km = (e as CustomEvent<{ km: number }>).detail?.km
      if (typeof km === "number") this.setDriveRadius(km)
    }
    window.addEventListener("rover:drive-radius", this.driveRadiusListener)

    this.handleEvent("stations_updated", ({ stations }: { stations: Station[] }) => {
      this.stations = stations
      this.renderStations()
    })

    this.handleEvent("update_drive_radius", ({ km }: { km: number }) => {
      this.setDriveRadius(km)
    })

    this.handleEvent("update_home", ({ lat, lon }: { lat: number; lon: number }) => {
      this.homeLat = lat
      this.homeLon = lon
      if (this.homeMarker) this.homeMarker.setLatLng([lat, lon])
      if (this.driveCircle) {
        this.driveCircle.setLatLng([lat, lon])
        this.map.flyToBounds(this.driveCircle.getBounds(), { padding: [20, 20], duration: 0.6 })
      } else {
        this.map.flyTo([lat, lon], Math.max(this.map.getZoom(), 8), { duration: 0.6 })
      }
    })

    this.handleEvent("rover_results", (
      { cells, drive_radius_km }: { cells: Cell[]; drive_radius_km: number }
    ) => {
      this.cellLookup = new Map()
      for (const c of cells) {
        this.cellLookup.set(cellKey(c.lat, c.lon), c)
      }
      // Update radius before (re)drawing so the per-pixel clip uses the
      // new value, otherwise the first paint can leak past the circle.
      this.setDriveRadius(drive_radius_km)
      if (!this.qualityLayer) {
        this.qualityLayer = this.buildQualityLayer()
        this.qualityLayer.addTo(this.map)
      } else {
        this.qualityLayer.redraw()
      }
    })

    this.handleEvent("focus_cell", (
      { lat, lon, zoom }: { lat: number; lon: number; zoom: number }
    ) => {
      // Drop a single highlight marker at the picked spot, replacing
      // any previous one — clicking another candidate moves it.
      if (this.selectedCandidateMarker) {
        this.map.removeLayer(this.selectedCandidateMarker)
      }
      this.selectedCandidateMarker = L.circleMarker([lat, lon], {
        radius: 8,
        color: "#0ea5e9",
        weight: 2,
        fillColor: "#0ea5e9",
        fillOpacity: 0.6,
        pane: "markerPane"
      }).addTo(this.map)

      this.map.flyTo([lat, lon], zoom, { duration: 0.6 })
    })

    this.handleEvent("candidate_paths", (
      { candidate, paths }: {
        candidate: { lat: number; lon: number } | null
        paths: { callsign: string; station_lat: number; station_lon: number; margin_db: number | null }[]
      }
    ) => {
      this.candidatePathLayer.clearLayers()

      if (!candidate) {
        if (this.selectedCandidateMarker) {
          this.map.removeLayer(this.selectedCandidateMarker)
          this.selectedCandidateMarker = null
        }
        return
      }

      if (!paths || paths.length === 0) return

      for (const p of paths) {
        L.polyline(
          [[candidate.lat, candidate.lon], [p.station_lat, p.station_lon]],
          { color: "#6366f1", weight: 2, dashArray: "6 4", opacity: 0.85 }
        ).addTo(this.candidatePathLayer)
      }
    })

    this.handleEvent("candidate_buildings", (
      { buildings }: {
        buildings: { lat: number; lon: number; radius_m: number; height_m: number }[]
      }
    ) => {
      this.candidateBuildingsLayer.clearLayers()
      if (!buildings || buildings.length === 0) return

      for (const b of buildings) {
        // Color by height: tall = red, medium = orange, low = yellow.
        const color = b.height_m >= 30 ? "#dc2626" : b.height_m >= 10 ? "#f97316" : "#facc15"
        L.circle([b.lat, b.lon], {
          radius: Math.max(b.radius_m, 8),
          color,
          weight: 1,
          fillColor: color,
          fillOpacity: 0.45,
          interactive: true
        })
          .bindTooltip(`${Math.round(b.height_m * 3.28084)} ft`, { direction: "top", offset: [0, -4] })
          .addTo(this.candidateBuildingsLayer)
      }
    })
  },

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

  destroyed(this: RoverMapHook) {
    if (this.visibilityHandler) {
      document.removeEventListener("visibilitychange", this.visibilityHandler)
      this.visibilityHandler = null
    }
    if (this.driveRadiusListener) {
      window.removeEventListener("rover:drive-radius", this.driveRadiusListener)
      this.driveRadiusListener = null
    }
  },

  setDriveRadius(this: RoverMapHook, km: number) {
    this.driveRadiusKm = km
    if (this.driveCircle) {
      this.driveCircle.setRadius(kmToM(km))
      this.driveCircle.setLatLng([this.homeLat, this.homeLon])
    }
  },

  renderStations(this: RoverMapHook) {
    this.stationMarkers.clearLayers()
    for (const s of this.stations) {
      this.stationMarkers.addLayer(buildStationMarker(s))
    }
  },

  buildQualityLayer(this: RoverMapHook): L.GridLayer {
    const self = this

    const OverlayClass = L.GridLayer.extend({
      createTile(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 w = size.x
        const h = size.y

        // tile lat/lon bounds
        const nwPoint = coords.scaleBy(size)
        const nw = this._map.unproject(nwPoint, coords.z)
        const se = this._map.unproject(nwPoint.add(size), coords.z)

        const latPerPx = (nw.lat - se.lat) / h        // positive (lat decreases going down)
        const lonPerPx = (se.lng - nw.lng) / w
        if (latPerPx <= 0 || lonPerPx <= 0) return tile

        // 0.125° grid spacing in pixels
        const cellPxX = 0.125 / lonPerPx
        const cellPxY = 0.125 / latPerPx
        const cellPx = Math.max(1, Math.min(cellPxX, cellPxY))

        const splatRadius = Math.ceil(3 * cellPx)
        const sigma = 1.2 * cellPx
        const twoSigmaSq = 2 * sigma * sigma

        // padding cell radius for the candidate filter (3 cells in degrees)
        const latPad = 3 * 0.125
        const lonPad = 3 * 0.125
        const minLat = se.lat - latPad
        const maxLat = nw.lat + latPad
        const minLon = nw.lng - lonPad
        const maxLon = se.lng + lonPad

        const tierBuffers: Record<string, Float32Array> = {
          "#16a34a": new Float32Array(w * h),
          "#eab308": new Float32Array(w * h),
          "#f97316": new Float32Array(w * h)
        }

        for (const cell of self.cellLookup.values()) {
          if (cell.lat < minLat || cell.lat > maxLat) continue
          if (cell.lon < minLon || cell.lon > maxLon) continue

          const tier = tierForScore(cell.score)
          if (!tier) continue

          // Cells may carry an explicit tier_color from the server; honor it.
          const useTier = TIER_RGB[cell.tier_color] ? cell.tier_color : tier
          const buf = tierBuffers[useTier]
          if (!buf) continue

          const cx = (cell.lon - nw.lng) / lonPerPx
          const cy = (nw.lat - cell.lat) / latPerPx

          const x0 = Math.max(0, Math.floor(cx - splatRadius))
          const x1 = Math.min(w - 1, Math.ceil(cx + splatRadius))
          const y0 = Math.max(0, Math.floor(cy - splatRadius))
          const y1 = Math.min(h - 1, Math.ceil(cy + splatRadius))

          for (let py = y0; py <= y1; py++) {
            const dy = py - cy
            for (let px = x0; px <= x1; px++) {
              const dx = px - cx
              const d2 = dx * dx + dy * dy
              const a = Math.exp(-d2 / twoSigmaSq)
              const idx = py * w + px
              if (a > buf[idx]) buf[idx] = a
            }
          }
        }

        // Clip the heatmap to the drive-radius circle: pixels farther
        // than driveRadiusKm from home stay transparent so the overlay
        // never extends past the dashed circle.
        const cosLat = Math.cos(self.homeLat * Math.PI / 180)
        const kmPerPxX = lonPerPx * 111.0 * Math.max(cosLat, 0.1)
        const kmPerPxY = latPerPx * 111.0
        const homePxX = (self.homeLon - nw.lng) / lonPerPx
        const homePxY = (nw.lat - self.homeLat) / latPerPx
        const radiusKmSq = self.driveRadiusKm * self.driveRadiusKm

        const img = ctx.createImageData(w, h)
        const data = img.data

        for (let py = 0; py < h; py++) {
          const dyKm = (py - homePxY) * kmPerPxY
          for (let px = 0; px < w; px++) {
            const dxKm = (px - homePxX) * kmPerPxX
            if (dxKm * dxKm + dyKm * dyKm > radiusKmSq) continue

            const i = py * w + px
            let bestAlpha = 0
            let bestRgb: [number, number, number] | null = null

            for (const tierColor of TIER_ORDER) {
              const a = tierBuffers[tierColor][i]
              if (a > 0.05 && a > bestAlpha) {
                bestAlpha = a
                bestRgb = TIER_RGB[tierColor]
              }
            }

            if (bestRgb && bestAlpha > 0) {
              const o = i * 4
              data[o] = bestRgb[0]
              data[o + 1] = bestRgb[1]
              data[o + 2] = bestRgb[2]
              data[o + 3] = Math.min(255, Math.round(bestAlpha * 0.35 * 255))
            }
          }
        }

        ctx.putImageData(img, 0, 0)
        return tile
      }
    })

    return new OverlayClass({ opacity: 1.0 }) as L.GridLayer
  }
}
