import {formatDistanceKm} from "./format"

// Contact tuple indices: [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id]
type ContactTuple = [
  number, number, number, number, number,
  string | null, string | null, string | null,
  number | null, string | null, string
]

interface ApplyFilterPayload {
  callsign: string | null
}

interface LineEntry {
  line: L.Polyline
  band: number
  s1: string
  s2: string
  lat1: number
  lon1: number
  lat2: number
  lon2: number
}

interface ContactsMapHook extends ViewHook {
  allContacts: ContactTuple[]
  callsignFilter: string
  enabledBands: Set<number>
  bandGroups: Map<number, L.LayerGroup>
  lines: LineEntry[]
  dotLayer: L.LayerGroup
  map: L.Map
  delegatedChange: (e: Event) => void
  delegatedClick: (e: Event) => void
  themeObserver: MutationObserver | null
  initMap(this: ContactsMapHook): void
  buildLines(this: ContactsMapHook): void
  syncBandLayers(this: ContactsMapHook): void
  applyCallsignFilter(this: ContactsMapHook): void
  rebuildDots(this: ContactsMapHook): void
  updateCount(this: ContactsMapHook, count: number): void
  wireControls(this: ContactsMapHook): void
  unwireControls(this: ContactsMapHook): void
  restyleLines(this: ContactsMapHook): void
}

// Amateur-band palette. Nine of these map to the design system's --band-*
// CSS vars (defined in assets/css/app.css); the higher mm-wave bands above
// 47 GHz fall back to hard-coded hues since the design set only spans up
// to 6mm. CSS vars are resolved once per page via getComputedStyle because
// Leaflet writes the color onto the SVG `stroke` attribute, which does not
// evaluate `var()` references.
const BAND_VAR: Record<number, string> = {
  1296:  "--band-23cm",
  2304:  "--band-13cm",
  3400:  "--band-9cm",
  5760:  "--band-6cm",
  10000: "--band-3cm",
  24000: "--band-1-25cm",
  47000: "--band-6mm",
}

const BAND_FALLBACK: Record<number, string> = {
  68000:  "#dc2626",
  75000:  "#c026d3",
  122000: "#db2777",
  134000: "#e11d48",
  241000: "#b91c1c",
}

let BAND_COLORS: Record<number, string> | null = null

function resolveBandColors(): Record<number, string> {
  if (BAND_COLORS) return BAND_COLORS
  const styles = getComputedStyle(document.documentElement)
  const resolved: Record<number, string> = { ...BAND_FALLBACK }
  for (const [band, cssVar] of Object.entries(BAND_VAR)) {
    const value = styles.getPropertyValue(cssVar).trim()
    if (value) resolved[Number(band)] = value
  }
  BAND_COLORS = resolved
  return resolved
}

function invalidateBandColors(): void {
  BAND_COLORS = null
}

function bandLabel(mhz: number): string {
  if (mhz >= 1000) {
    const ghz = mhz / 1000
    return (Number.isInteger(ghz) ? ghz : ghz.toFixed(1)) + " GHz"
  }
  return mhz + " MHz"
}

function bandColor(band: number): string {
  return resolveBandColors()[band] || "#64748b"
}

export const ContactsMap = {
  mounted(this: ContactsMapHook) {
    const fetchUrl = this.el.dataset.fetchUrl || "/api/contacts/map"
    this.allContacts = []
    this.callsignFilter = ""
    this.enabledBands = new Set()
    this.bandGroups = new Map()
    this.lines = []
    this.dotLayer = L.layerGroup()
    this.themeObserver = null

    // Callsign filter still roundtrips through LiveView (debounced) — it's
    // cross-cutting and fires rarely. Band toggles are purely client-side.
    this.handleEvent("apply_filter", ({callsign}: ApplyFilterPayload) => {
      this.callsignFilter = (callsign || "").toUpperCase()
      this.applyCallsignFilter()
      this.rebuildDots()
    })

    this.wireControls()

    // Re-color polylines when the theme toggles. Leaflet writes `stroke` as
    // an SVG attribute (which does not resolve CSS vars), so we re-read the
    // resolved --band-* values and call setStyle on each line.
    this.themeObserver = new MutationObserver(() => {
      invalidateBandColors()
      this.restyleLines()
    })
    this.themeObserver.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["data-theme"]
    })

    requestAnimationFrame(() => this.initMap())

    fetch(fetchUrl)
      .then(resp => resp.json())
      .then(contacts => {
        this.allContacts = contacts
        for (const c of contacts) this.enabledBands.add(c[4])
        if (this.map) {
          this.buildLines()
          this.syncBandLayers()
          this.rebuildDots()
        }
      })
      .catch(err => console.error("Failed to load contacts:", err))
  },

  destroyed(this: ContactsMapHook) {
    this.unwireControls()
    if (this.themeObserver) {
      this.themeObserver.disconnect()
      this.themeObserver = null
    }
  },

  restyleLines(this: ContactsMapHook) {
    for (const entry of this.lines) {
      entry.line.setStyle({ color: bandColor(entry.band) })
    }
  },

  wireControls(this: ContactsMapHook) {
    // Band checkboxes and All/None buttons live outside this hook's element
    // (they're in the sidebar), so delegate from the document. The checkboxes
    // have data-band and the bulk buttons have data-band-action="all"|"none".
    this.delegatedChange = (e: Event) => {
      const t = e.target as HTMLInputElement
      if (!t || !t.matches || !t.matches("input[data-band]")) return
      const band = parseInt(t.dataset.band || "", 10)
      if (!Number.isFinite(band)) return
      if (t.checked) this.enabledBands.add(band)
      else this.enabledBands.delete(band)
      this.syncBandLayers()
      this.rebuildDots()
    }

    this.delegatedClick = (e: Event) => {
      const t = e.target as HTMLElement
      if (!t || !t.matches || !t.matches("[data-band-action]")) return
      const action = t.dataset.bandAction
      const inputs = document.querySelectorAll<HTMLInputElement>("input[data-band]")
      if (action === "all") {
        this.enabledBands = new Set()
        for (const inp of inputs) {
          inp.checked = true
          const band = parseInt(inp.dataset.band || "", 10)
          if (Number.isFinite(band)) this.enabledBands.add(band)
        }
      } else if (action === "none") {
        this.enabledBands = new Set()
        for (const inp of inputs) inp.checked = false
      } else {
        return
      }
      this.syncBandLayers()
      this.rebuildDots()
    }

    document.addEventListener("change", this.delegatedChange)
    document.addEventListener("click", this.delegatedClick)
  },

  unwireControls(this: ContactsMapHook) {
    if (this.delegatedChange) document.removeEventListener("change", this.delegatedChange)
    if (this.delegatedClick) document.removeEventListener("click", this.delegatedClick)
  },

  initMap(this: ContactsMapHook) {
    this.map = L.map(this.el, {
      center: [37.5, -96],
      zoom: 5,
      minZoom: 3,
      maxZoom: 14,
      preferCanvas: true
    })

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

    this.dotLayer.addTo(this.map)

    if (this.allContacts.length > 0) {
      for (const c of this.allContacts) this.enabledBands.add(c[4])
      this.buildLines()
      this.syncBandLayers()
      this.rebuildDots()
    }
  },

  buildLines(this: ContactsMapHook) {
    // One-time build of every polyline, grouped into per-band LayerGroups.
    // After this, band toggling is just addLayer/removeLayer on the map —
    // no polyline recreation ever.
    this.lines = []
    this.bandGroups = new Map()

    for (const c of this.allContacts) {
      const [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id] = c
      const color = bandColor(band)

      const line = L.polyline([[lat1, lon1], [lat2, lon2]], {
        color, weight: 2, opacity: 0.5
      })

      const distStr = formatDistanceKm(dist)
      line.bindPopup(
        `<div style="font-size:12px;line-height:1.5;">` +
        `<strong>${s1 || "?"} &harr; ${s2 || "?"}</strong><br/>` +
        `${bandLabel(band)} &middot; ${mode || "?"}<br/>` +
        `${distStr} &middot; ${ts || "?"} UTC<br/>` +
        `<a href="/contacts/${id}" style="color:#3b82f6;">View details &rarr;</a>` +
        `</div>`,
        {closeButton: false, offset: [0, -4]}
      )

      line.on("mouseover", function(this: L.Polyline) { this.setStyle({weight: 4, opacity: 1}) })
      line.on("mouseout", function(this: L.Polyline) { this.setStyle({weight: 2, opacity: 0.5}) })

      let group = this.bandGroups.get(band)
      if (!group) {
        group = L.layerGroup()
        this.bandGroups.set(band, group)
      }
      group.addLayer(line)

      this.lines.push({
        line,
        band,
        s1: (s1 || "").toUpperCase(),
        s2: (s2 || "").toUpperCase(),
        lat1, lon1, lat2, lon2
      })
    }
  },

  syncBandLayers(this: ContactsMapHook) {
    // Fast path: flip whole-group on/off. O(bands), not O(contacts).
    for (const [band, group] of this.bandGroups) {
      const enabled = this.enabledBands.has(band)
      const present = this.map.hasLayer(group)
      if (enabled && !present) this.map.addLayer(group)
      else if (!enabled && present) this.map.removeLayer(group)
    }
  },

  applyCallsignFilter(this: ContactsMapHook) {
    // Callsign filter: iterate individual polylines and add/remove them
    // from their band's group. Cheap compared to rebuild (no polyline
    // creation). Only runs when the filter string changes.
    const match = this.callsignFilter
    for (const entry of this.lines) {
      const group = this.bandGroups.get(entry.band)
      if (!group) continue
      const visible = !match || entry.s1.includes(match) || entry.s2.includes(match)
      const present = group.hasLayer(entry.line)
      if (visible && !present) group.addLayer(entry.line)
      else if (!visible && present) group.removeLayer(entry.line)
    }
  },

  rebuildDots(this: ContactsMapHook) {
    this.dotLayer.clearLayers()
    const match = this.callsignFilter
    const endpointCounts: Record<string, number> = {}
    let lineCount = 0

    for (const entry of this.lines) {
      if (!this.enabledBands.has(entry.band)) continue
      if (match && !entry.s1.includes(match) && !entry.s2.includes(match)) continue
      lineCount++
      const k1 = `${entry.lat1.toFixed(2)},${entry.lon1.toFixed(2)}`
      const k2 = `${entry.lat2.toFixed(2)},${entry.lon2.toFixed(2)}`
      endpointCounts[k1] = (endpointCounts[k1] || 0) + 1
      endpointCounts[k2] = (endpointCounts[k2] || 0) + 1
    }

    for (const [key, count] of Object.entries(endpointCounts)) {
      const [lat, lon] = key.split(",").map(Number)
      const radius = Math.min(2 + Math.log2(count) * 1.5, 10)

      L.circleMarker([lat, lon], {
        radius, color: "#fff", weight: 1,
        fillColor: "#3b82f6", fillOpacity: 0.7,
        interactive: false
      }).addTo(this.dotLayer)
    }

    this.updateCount(lineCount)
  },

  updateCount(this: ContactsMapHook, count: number) {
    const label = `${count.toLocaleString()} contacts`
    const countEl = document.getElementById("contact-count")
    if (countEl) countEl.textContent = label
    const countMobile = document.getElementById("contact-count-mobile")
    if (countMobile) countMobile.textContent = label
  }
}
