import { html, LitElement, nothing, type TemplateResult } from 'lit'
import maplibregl from 'maplibre-gl'
import { protocol } from '../utils/pmtiles-protocol.ts'
import { debounce } from '@std/async/debounce'
import {
  downloadAndSavePMTiles,
  getCachedPMTiles,
  isPMTilesCached,
} from '../utils/fs.ts'
import { getMapNav, MAP_NAV_EVENT, setMapNav } from '../utils/nav.ts'
import {
  getListFilter,
  LIST_FILTER_EVENT,
  setListFilter,
} from '../utils/list-view.ts'
import {
  getFavoritesFilter,
  getListFilterIds,
  getVisitedFilter,
  MAP_FILTER_EVENT,
  setFavoritesFilter,
  setVisitedFilter,
  toggleListFilterId,
  type VisitedFilter,
} from '../utils/map-filters.ts'
import {
  buildWorldLayers,
  default as buildRegionalLayers,
  isTransitLayer,
  mapBackground,
} from '../utils/layers.ts'
import { isDarkTheme, watchMapTheme } from '../utils/map-theme.ts'
import { ensureTrainStationIcon } from '../utils/map-icons.ts'
import { ensureStationLogoIcons } from '../utils/station-networks.ts'
import {
  applyLayerPalette,
  attachRegionalSource,
  detachRegionalSource,
} from '../utils/regional-sources.ts'
import { PIN_GREEN } from '../utils/colors.ts'
import app from '../store/app.ts'
import { type Bookmark, bookmarkDisplayName } from '../store/schema.ts'
import { boundsIntersect, expandBounds } from '../utils/bounds.ts'
import {
  canStreamTiles,
  centroidDistanceSq,
  dataSaverActive,
  MIN_ONLINE_ZOOM,
  pickStreamableRegions,
  streamedTileUrl,
  VIEWPORT_BUFFER,
} from '../utils/online-tiles.ts'
import {
  fetchTileManifest,
  parentLabel,
  type TileManifestEntry,
} from '../utils/tile-manifest.ts'
import {
  buildBookmarksGeoJSON,
  ensureBookmarkLayersOnTop,
  setupBookmarkLayers,
} from '../utils/bookmark-layers.ts'
import { createBookmarkAddPopup } from './m-bookmark-add-popup.ts'
import { createBookmarkViewPopup } from './m-bookmark-view-popup.ts'
import { icon } from '../utils/icon.ts'

const registeredSources = new Set<string>()

// World tiles cover lower zooms; regional sources only pull their weight
// at street-level detail, so keep them out of the style until then.
const MIN_REGIONAL_ZOOM = 6

/**
 * Where a regional source reads its archive from — a downloaded copy in
 * IndexedDB, or the same file streamed over HTTP range requests. Tracked per
 * source so a region that finishes downloading (or loses its connection)
 * swaps backing stores instead of being mistaken for already-attached.
 */
type SourceMode = 'idb' | 'net'

/**
 * What the map's streaming badge should say. `hidden` covers every case where
 * the toggle wouldn't change what's on screen — too far out, nothing
 * streamable in view, or no connection at all — so the badge only appears
 * when it's actionable.
 */
type OnlineStatus = 'hidden' | 'streaming' | 'off' | 'saver'

export class MMap extends LitElement {
  #map: maplibregl.Map | null = null
  /**
   * Latches on the map's one-time `load` event. Store updates, connectivity
   * changes, and `pmtiles-updated` can all arrive before the style has
   * loaded — `addSource` then throws "Style is not done loading" and, worse,
   * an early failure in the load handler leaves the pin layers missing. The
   * load handler re-runs everything these gates skip.
   */
  #styleReady = false
  #marker: maplibregl.Marker | null = null
  #bookmarkPopup: maplibregl.Popup | null = null
  #geolocate: maplibregl.GeolocateControl | null = null
  #longPressTimer: ReturnType<typeof setTimeout> | null = null
  #tileManifest: TileManifestEntry[] = []
  #availableTile: TileManifestEntry | null = null
  #confirmedCached = new Set<string>()
  #activeRegionalSources = new Map<string, SourceMode>()
  #lastOnlineTilesEnabled = app.onlineTilesEnabled
  #lastTransitLayerEnabled = app.transitLayerEnabled
  #onlineStatus: OnlineStatus = 'hidden'
  #menuOpen = false
  #currentZoom = 0
  #syncQueue: Promise<void> = Promise.resolve()
  #syncPending = false
  /** Resolved light/dark palette — see `utils/map-theme.ts`. Kept as state
   * because MapLibre paint expressions can't read CSS custom properties, so
   * a theme flip has to be re-applied to every attached layer by hand. */
  #dark = isDarkTheme()
  #stopWatchingTheme: (() => void) | null = null

  protected override createRenderRoot() {
    return this
  }

  override connectedCallback() {
    super.connectedCallback()
    app.addEventListener(this.#onAppUpdate)
    globalThis.addEventListener('pmtiles-updated', this.#onPMTilesUpdated)
    globalThis.addEventListener(MAP_NAV_EVENT, this.#onMapNav)
    globalThis.addEventListener(LIST_FILTER_EVENT, this.#onListFilterChange)
    globalThis.addEventListener(MAP_FILTER_EVENT, this.#onMapFilterChange)
    document.addEventListener('click', this.#onDocumentClick)
    globalThis.addEventListener('online', this.#onConnectivityChange)
    globalThis.addEventListener('offline', this.#onConnectivityChange)
    navigator.connection?.addEventListener('change', this.#onConnectivityChange)
    this.#stopWatchingTheme = watchMapTheme(this.#onThemeChange)
  }

  #onThemeChange = (dark: boolean): void => {
    if (dark === this.#dark) return
    this.#dark = dark
    if (!this.#map) return
    this.#map.setPaintProperty(
      'background',
      'background-color',
      mapBackground(dark),
    )
    if (this.#map.getSource('world')) {
      applyLayerPalette(this.#map, buildWorldLayers(dark))
    }
    for (const id of this.#activeRegionalSources.keys()) {
      applyLayerPalette(this.#map, buildRegionalLayers(id, dark))
    }
  }

  #onMoveEnd = debounce(() => {
    this.#saveLastView()
    this.#checkAvailableTile()
    this.#syncRegionalTilesForViewport()
    this.#currentZoom = this.#map?.getZoom() ?? 0
    this.requestUpdate()
  }, 150)

  #onAppUpdate = () => {
    // `app.bookmarkValues`/`app.listValues` are backed by a `Map` that's
    // mutated in place on every change, so its reference never changes —
    // there's no cheap dirty-check available here. Re-render unconditionally;
    // `#renderBookmarkMarkers` is just a `setData` call, so this is fine to
    // run on every store notification.
    this.#renderBookmarkMarkers()
    // The map menu lists every bookmark list for the list filter — keep it
    // current if a list is renamed/added/deleted while the menu is open.
    this.requestUpdate()
    const online = app.onlineTilesEnabled
    if (online !== this.#lastOnlineTilesEnabled) {
      this.#lastOnlineTilesEnabled = online
      void this.#syncRegionalTilesForViewport()
    }
    const transit = app.transitLayerEnabled
    if (transit !== this.#lastTransitLayerEnabled) {
      this.#lastTransitLayerEnabled = transit
      this.#setTransitLayersVisible(transit)
    }
  }

  #onPMTilesUpdated = async () => {
    // A finished download (or deletion) may concern a world archive, not
    // just a regional one — refresh the basemap too.
    await this.#loadWorldTiles()
      .catch((err) => console.error('World tile reload failed:', err))
    await this.#syncRegionalTilesForViewport()
  }

  // Losing the connection drops streamed sources (their tile requests would
  // only hang); regaining it brings them back.
  #onConnectivityChange = () => {
    void this.#syncRegionalTilesForViewport()
  }

  // Entering or leaving a list view changes which pins exist; framing the
  // whole list is the point of opening it. The pin refresh/fit is gated on
  // the style latch rather than `loaded()`, which is false whenever any
  // tile is still in flight — but the menu's "Filtering by …" row (see
  // `#renderFilterControls`) needs `requestUpdate()` regardless, since it's
  // the only way the "Clear" button's effect becomes visible.
  #onListFilterChange = () => {
    this.requestUpdate()
    if (!this.#map || !this.#styleReady) return
    this.#renderBookmarkMarkers()
    this.#fitToActiveList()
  }

  // Visited/list filters are live toggles rather than a dedicated view, so —
  // unlike setting the exclusive list scope — they don't reframe the map.
  #onMapFilterChange = () => {
    this.#renderBookmarkMarkers()
    this.requestUpdate()
  }

  /** Frames every pin in the active list, once one is active. */
  #fitToActiveList(): void {
    if (!this.#map || !getListFilter()) return
    const points = [...this.#visibleBookmarks()]
      .map((b) => b.geometry.coordinates)
    if (!points.length) return
    if (points.length === 1) {
      this.#map.easeTo({ center: points[0], zoom: 14 })
      return
    }
    const bounds = points.reduce(
      (acc, c) => acc.extend(c),
      new maplibregl.LngLatBounds(points[0], points[0]),
    )
    this.#map.fitBounds(bounds, { padding: 64, maxZoom: 15 })
  }

  override firstUpdated(): void {
    const container = this.querySelector<HTMLElement>('#map')
    if (!container) return

    const lastView = app.lastView

    this.#map = new maplibregl.Map({
      container,
      center: lastView ? [lastView.lng, lastView.lat] : [20, 20],
      zoom: lastView ? lastView.zoom : 2,
      pitchWithRotate: false,
      attributionControl: false,
      style: {
        version: 8,
        glyphs: '/static/fonts/{fontstack}/{range}.pbf',
        layers: [
          {
            id: 'background',
            type: 'background',
            paint: { 'background-color': mapBackground(this.#dark) },
          },
        ],
        sources: {},
      },
    })

    this.#map.addControl(
      new maplibregl.AttributionControl({ compact: true }),
      'top-right',
    )

    this.#geolocate = new maplibregl.GeolocateControl({
      positionOptions: { enableHighAccuracy: true },
      trackUserLocation: true,
    })
    this.#map.addControl(this.#geolocate, 'bottom-right')

    this.#map.on('moveend', this.#onMoveEnd)

    this.#map.on('contextmenu', (e) => {
      this.#placePin(e.lngLat)
    })

    this.#map.on('touchstart', (e) => {
      if (e.originalEvent.touches.length !== 1) return
      const lngLat = e.lngLat
      this.#longPressTimer = setTimeout(() => this.#placePin(lngLat), 500)
    })
    this.#map.on('touchend', () => this.#clearLongPress())
    this.#map.on('touchmove', () => this.#clearLongPress())
    this.#map.on('drag', () => this.#clearLongPress())

    this.#map.on('load', async () => {
      this.#styleReady = true
      this.#currentZoom = this.#map!.getZoom()
      // A failed world load (e.g. unreadable IndexedDB data) must not skip
      // the rest of the boot — pins and regional sync stand on their own.
      await this.#loadWorldTiles()
        .catch((err) => console.error('World tile load failed:', err))
      await this.#syncRegionalTilesForViewport()
      this.#renderBookmarkMarkers()
      ensureBookmarkLayersOnTop(this.#map!)
      // Deep-linking straight into `/list/{id}` sets the filter before the
      // map exists, so the framing has to happen here too. A pending nav
      // target is more specific, so it gets the last word.
      this.#fitToActiveList()
      this.#applyPendingNav()
      // `hidden` is set by the router: only geolocate when the map is what
      // the user opened, not behind a deep link into another route.
      if (app.trackOnInitialLoad && !this.hidden) this.#geolocate?.trigger()
      // The compact attribution expands once the first source with
      // attribution loads; minimize it after a beat, the same way
      // MapLibre's own drag handler does.
      setTimeout(() => {
        this.querySelector('.maplibregl-ctrl-attrib')
          ?.classList.remove('maplibregl-compact-show')
      }, 2000)
    })
  }

  #onMapNav = () => {
    if (!this.#map?.loaded()) return
    // Nav events often arrive while m-map is still hidden (the triggering
    // route hasn't finished the hashchange → unhide cycle yet). Defer a
    // frame so layout settles, then resize so flyTo uses real dimensions.
    requestAnimationFrame(() => {
      if (!this.#map) return
      this.#map.resize()
      this.#applyPendingNav()
    })
  }

  #applyPendingNav(): void {
    if (!this.#map) return
    const target = getMapNav()
    if (!target) return
    setMapNav(null)
    this.#map.flyTo({
      center: [target.lng, target.lat],
      zoom: target.zoom,
    })
    if (target.marker) {
      this.#marker?.remove()
      this.#marker = new maplibregl.Marker({ color: PIN_GREEN })
        .setLngLat([target.lng, target.lat])
        .setPopup(
          createBookmarkAddPopup({
            lat: target.lat,
            lng: target.lng,
            zoom: target.zoom,
            initialName: target.name ?? 'Unknown location',
            address: target.address,
          }),
        )
        .addTo(this.#map)
      this.#marker.togglePopup()
    } else if (target.bookmarkId) {
      this.#showBookmarkPopup(target.bookmarkId)
    }
  }

  #saveLastView() {
    if (!this.#map) return
    const { lat, lng } = this.#map.getCenter()
    const zoom = this.#map.getZoom()
    app.setLastView({ lat, lng, zoom })
  }

  #clearLongPress() {
    if (this.#longPressTimer) {
      clearTimeout(this.#longPressTimer)
      this.#longPressTimer = null
    }
  }

  #placePin(lngLat: maplibregl.LngLat) {
    if (!this.#map) return
    const { lat, lng } = lngLat
    const zoom = this.#map.getZoom()
    const popup = createBookmarkAddPopup({ lat, lng, zoom, geocode: true })
    this.#marker?.remove()
    this.#marker = new maplibregl.Marker()
      .setLngLat([lng, lat])
      .setPopup(popup)
      .addTo(this.#map)
    this.#marker.togglePopup()
  }

  #showBookmarkPopup(bookmarkId: string) {
    if (!this.#map) return
    const bookmark = app.bookmarks.find((b) => b.id === bookmarkId)
    if (!bookmark) return
    const [lng, lat] = bookmark.geometry.coordinates
    this.#bookmarkPopup?.remove()
    this.#bookmarkPopup = createBookmarkViewPopup({
      bookmarkId,
      displayName: bookmarkDisplayName(bookmark),
    })
      .setLngLat([lng, lat])
      .addTo(this.#map)
  }

  /**
   * Bookmarks the map should show: everything, narrowed by the exclusive
   * list scope (`getListFilter` — set by "View on map" on a list's own
   * page; wins over the menu's list filter, since that filter is meant for
   * the unscoped view) and by the menu's visited/list filters.
   */
  #visibleBookmarks(): Iterable<Bookmark> {
    const all = [...(app.bookmarkValues?.values() ?? [])]
    const listId = getListFilter()
    const filterIds = getListFilterIds()
    const visited = getVisitedFilter()
    const favoritesOnly = getFavoritesFilter()
    return all.filter((b) => {
      if (listId) {
        if (!b.categories.includes(listId)) return false
      } else if (filterIds.size > 0) {
        if (!b.categories.some((c) => filterIds.has(c))) return false
      }
      if (visited === 'visited' && !b.visited) return false
      if (visited === 'unvisited' && b.visited) return false
      if (favoritesOnly && !b.favorite) return false
      return true
    })
  }

  #renderBookmarkMarkers() {
    if (!this.#map || !this.#styleReady) return
    const data = buildBookmarksGeoJSON(
      this.#visibleBookmarks(),
      app.listValues?.values() ?? [],
      getListFilter(),
    )
    const source = this.#map.getSource('bookmarks') as
      | maplibregl.GeoJSONSource
      | undefined
    if (source) {
      source.setData(data)
      return
    }
    setupBookmarkLayers(this.#map, data, {
      onClusterClick: async (clusterId, coords, pointCount) => {
        if (!this.#map) return
        const src = this.#map.getSource(
          'bookmarks',
        ) as maplibregl.GeoJSONSource
        const baseZoom = await src.getClusterExpansionZoom(clusterId)
        const extraZoom = pointCount > 50 ? 2 : pointCount > 20 ? 1 : 0
        this.#map.easeTo({ center: coords, zoom: baseZoom + extraZoom })
      },
      onBookmarkClick: (coords, id, displayName) => {
        if (!this.#map) return
        this.#bookmarkPopup?.remove()
        this.#bookmarkPopup = createBookmarkViewPopup({
          bookmarkId: id,
          displayName,
        })
          .setLngLat(coords)
          .addTo(this.#map)
      },
    })
  }

  /**
   * Shows/hides the transit route + station overlay across every currently
   * attached source (regional archives each register their own copies of
   * these layer ids — see `layers.ts`). Safe to call before any are
   * attached; `getStyle().layers` is just empty until the style loads.
   */
  #setTransitLayersVisible(visible: boolean): void {
    if (!this.#map) return
    const layers = this.#map.getStyle()?.layers ?? []
    for (const layer of layers) {
      if (!isTransitLayer(layer.id)) continue
      this.#map.setLayoutProperty(
        layer.id,
        'visibility',
        visible ? 'visible' : 'none',
      )
    }
  }

  async #checkAvailableTile(): Promise<void> {
    if (!this.#map) return
    const zoom = this.#map.getZoom()

    if (zoom < MIN_REGIONAL_ZOOM) {
      this.#availableTile = null
      this.requestUpdate()
      return
    }

    const manifest = await fetchTileManifest()
    this.#tileManifest = manifest

    const viewport = this.#map.getBounds()
    const viewportCenter = viewport.getCenter()

    let bestTile: TileManifestEntry | null = null
    let bestDistance = Infinity

    for (const tile of manifest) {
      // An attached source no longer implies the region is downloaded — a
      // streamed one is exactly the case where the offer still applies.
      if (this.#activeRegionalSources.get(tile.id) === 'idb') continue
      if (!tile.filename) continue
      if (this.#confirmedCached.has(tile.filename)) continue
      if (!tile.bounds) continue

      const [w, s, e, n] = tile.bounds
      const centroidLng = (w + e) / 2
      const centroidLat = (s + n) / 2

      if (!viewport.contains([centroidLng, centroidLat])) continue

      const cached = await isPMTilesCached(tile.filename)
      if (cached) {
        this.#confirmedCached.add(tile.filename)
        continue
      }

      const distance = centroidDistanceSq(
        tile.bounds,
        viewportCenter.lng,
        viewportCenter.lat,
      )

      if (distance < bestDistance) {
        bestDistance = distance
        bestTile = tile
      }
    }

    this.#availableTile = bestTile
    this.requestUpdate()
  }

  #handleDownloadClick = async () => {
    if (!this.#availableTile) return

    const parentGroup = this.#availableTile.group
    const parentId = this.#availableTile.id
    const manifest = this.#tileManifest

    if (this.#availableTile.filename) {
      try {
        await downloadAndSavePMTiles(
          `/static/tiles/${this.#availableTile.filename}`,
          this.#availableTile.filename,
        )
        await this.#syncRegionalTilesForViewport()
        this.#availableTile = null
        this.requestUpdate()
      } catch (err) {
        console.error('Download failed:', err)
      }
    } else {
      const tilesInGroup = manifest.filter(
        (t) => t.group === `${parentGroup}/${parentId}` && t.filename,
      )

      for (const tile of tilesInGroup) {
        if (!tile.filename) continue
        try {
          await downloadAndSavePMTiles(
            `/static/tiles/${tile.filename}`,
            tile.filename,
          )
        } catch (err) {
          console.error(`Download failed for ${tile.label}:`, err)
        }
      }

      await this.#syncRegionalTilesForViewport()
      this.#availableTile = null
      this.requestUpdate()
    }
  }

  #toggleMenu = (e: Event) => {
    e.stopPropagation()
    this.#menuOpen = !this.#menuOpen
    this.requestUpdate()
  }

  // Any click that isn't inside the menu dismisses it.
  #onDocumentClick = (e: Event) => {
    if (!this.#menuOpen) return
    const menu = this.querySelector('.map-menu')
    if (menu && !e.composedPath().includes(menu)) {
      this.#menuOpen = false
      this.requestUpdate()
    }
  }

  /**
   * Map options, opened from a single icon so the map stays uncluttered.
   * Holds the online-detail switch today; layer toggles belong here too.
   * Settings keeps the durable preference — this is the in-context switch,
   * since nobody hunts through Settings mid-pan.
   */
  #renderMapMenu(): TemplateResult {
    const status = this.#onlineStatus
    const saver = status === 'saver'

    return html`
      <div class="map-menu">
        <button
          class="map-menu-button"
          aria-label="Map options"
          aria-expanded="${this.#menuOpen}"
          @click="${this.#toggleMenu}"
        >
          ${icon('layers', '18px')}
        </button>
        ${this.#menuOpen
          ? html`
            <div class="map-menu-panel" role="menu">
              <label class="map-menu-row">
                <span>
                  Offline Mode
                  <span class="map-menu-hint">
                    Only use downloaded maps
                  </span>
                </span>
                <input
                  type="checkbox"
                  ?disabled="${saver}"
                  .checked="${!app.onlineTilesEnabled}"
                  @change="${(e: Event) =>
                    app.setOnlineTilesEnabled(
                      !(e.target as HTMLInputElement).checked,
                    )}"
                >
              </label>
              <label class="map-menu-row">
                <span>Transit lines</span>
                <input
                  type="checkbox"
                  .checked="${app.transitLayerEnabled}"
                  @change="${(e: Event) =>
                    app.setTransitLayerEnabled(
                      (e.target as HTMLInputElement).checked,
                    )}"
                >
              </label>
              ${this.#renderFilterControls()}
              <p class="map-menu-zoom">
                Zoom ${this.#currentZoom.toFixed(1)}
              </p>
            </div>
          `
          : nothing}
      </div>
    `
  }

  /**
   * Visited status is a plain tri-state select; list membership goes behind
   * a `<details>` disclosure since the list count is unbounded and most
   * users won't need it open. The exclusive list filter (set by "View on
   * map" from a list's own page, `routes/list.ts`) wins over this menu's
   * additive filter (see `#visibleBookmarks`) and isn't tied to a route
   * anymore, so it needs its own way out — a single row replaces the rest
   * of the menu while it's active, rather than hiding outright.
   */
  #renderFilterControls(): TemplateResult | typeof nothing {
    const activeListId = getListFilter()
    if (activeListId) {
      const name = app.bookmarkLists.find((l) => l.id === activeListId)?.name ??
        'list'
      return html`
        <div class="map-menu-row">
          <span>Filtering by ${name}</span>
          <button
            type="button"
            class="link"
            @click="${() => setListFilter(null)}"
          >
            Clear
          </button>
        </div>
      `
    }
    const lists = app.bookmarkLists
    const selected = getListFilterIds()

    return html`
      <label class="map-menu-row">
        <span>Favorites only</span>
        <input
          type="checkbox"
          .checked="${getFavoritesFilter()}"
          @change="${(e: Event) =>
            setFavoritesFilter((e.target as HTMLInputElement).checked)}"
        >
      </label>
      <label class="map-menu-row">
        <span>Visited</span>
        <select
          @change="${(e: Event) =>
            setVisitedFilter(
              (e.target as HTMLSelectElement).value as VisitedFilter,
            )}"
        >
          <option value="all" ?selected="${getVisitedFilter() === 'all'}">
            All
          </option>
          <option
            value="visited"
            ?selected="${getVisitedFilter() === 'visited'}"
          >
            Visited
          </option>
          <option
            value="unvisited"
            ?selected="${getVisitedFilter() === 'unvisited'}"
          >
            Not visited
          </option>
        </select>
      </label>
      ${lists.length > 0
        ? html`
          <details class="map-menu-lists">
            <summary>
              Filter by list${selected.size ? ` (${selected.size})` : ''}
            </summary>
            ${lists.map((l) =>
              html`
                <label class="map-menu-list-option">
                  <input
                    type="checkbox"
                    .checked="${selected.has(l.id)}"
                    @change="${(e: Event) =>
                      toggleListFilterId(
                        l.id,
                        (e.target as HTMLInputElement).checked,
                      )}"
                  >
                  <span>${l.name}</span>
                </label>
              `
            )}
          </details>
        `
        : nothing}
    `
  }

  override render(): TemplateResult {
    const tile = this.#availableTile
    const manifest = this.#tileManifest

    let label = ''
    if (tile) {
      const parent = manifest.length ? parentLabel(tile, manifest) : null
      label = parent ? `${tile.label} - ${parent}` : tile.label
    }

    return html`
      <div id="map"></div>
      ${this.#renderMapMenu()} ${tile
        ? html`
          <button
            class="download-btn"
            @click="${this.#handleDownloadClick}"
          >
            Download ${label}
          </button>
        `
        : null}
    `
  }

  override disconnectedCallback() {
    super.disconnectedCallback()
    app.removeEventListener(this.#onAppUpdate)
    globalThis.removeEventListener('pmtiles-updated', this.#onPMTilesUpdated)
    globalThis.removeEventListener(MAP_NAV_EVENT, this.#onMapNav)
    globalThis.removeEventListener(LIST_FILTER_EVENT, this.#onListFilterChange)
    globalThis.removeEventListener(MAP_FILTER_EVENT, this.#onMapFilterChange)
    document.removeEventListener('click', this.#onDocumentClick)
    globalThis.removeEventListener('online', this.#onConnectivityChange)
    globalThis.removeEventListener('offline', this.#onConnectivityChange)
    navigator.connection?.removeEventListener(
      'change',
      this.#onConnectivityChange,
    )
    this.#stopWatchingTheme?.()
    this.#stopWatchingTheme = null
    this.#onMoveEnd.clear()
    this.#clearLongPress()
    this.#bookmarkPopup?.remove()
    this.#bookmarkPopup = null
    this.#marker?.remove()
    this.#marker = null
    this.#map?.remove()
    this.#map = null
    this.#styleReady = false
  }

  /**
   * Attaches the best world basemap available — z7 or z6 if downloaded, else
   * the bundled z5 (fetched on first run). Safe to re-run: a later call swaps
   * the existing source's URL in place (layer order untouched), which is how
   * downloading or deleting a world archive takes effect without a reload.
   */
  async #loadWorldTiles(): Promise<void> {
    if (!this.#map || !this.#styleReady) return
    const [z7, z6] = await Promise.all([
      getCachedPMTiles('world/world_z7.pmtiles'),
      getCachedPMTiles('world/world_z6.pmtiles'),
    ])
    const cached = z7 ?? z6
    const filename = z7
      ? 'world/world_z7.pmtiles'
      : z6
      ? 'world/world_z6.pmtiles'
      : 'world/world_z5.pmtiles'

    // Keyed by filename, not 'world': each archive registers with the
    // protocol once, and the guard stays correct across upgrades.
    if (!registeredSources.has(filename)) {
      const pmtiles = cached ?? await downloadAndSavePMTiles(
        '/static/tiles/world/world_z5.pmtiles',
        filename,
      )
      protocol.add(pmtiles)
      registeredSources.add(filename)
    }

    const url = `pmtiles://${filename}`
    const existing = this.#map.getSource('world') as
      | maplibregl.VectorTileSource
      | undefined
    if (existing) {
      if (existing.url !== url) existing.setUrl(url)
      return
    }
    this.#map.addSource('world', {
      type: 'vector',
      url,
      attribution: '© OpenStreetMap contributors',
    })
    await Promise.all([
      ensureTrainStationIcon(this.#map),
      ensureStationLogoIcons(this.#map),
    ])
    buildWorldLayers(this.#dark).forEach((layer) =>
      this.#map!.addLayer(layer as maplibregl.AddLayerObject)
    )
  }

  async #addRegionalSource(
    tile: TileManifestEntry,
    mode: SourceMode,
  ): Promise<void> {
    if (!this.#map || !tile.filename) return

    let url: string
    if (mode === 'idb') {
      const pmtiles = await getCachedPMTiles(tile.filename)
      if (!pmtiles) return
      if (!registeredSources.has(tile.id)) {
        protocol.add(pmtiles)
        registeredSources.add(tile.id)
      }
      url = `pmtiles://${tile.filename}`
    } else {
      // Streamed archives need no protocol.add — Protocol builds a
      // FetchSource from the URL the first time a tile is requested. The key
      // is the full URL, so it can't collide with the IndexedDB source for
      // the same region (keyed by bare filename).
      url = streamedTileUrl(tile.filename)
    }

    attachRegionalSource(this.#map, tile.id, url, this.#dark)
    this.#activeRegionalSources.set(tile.id, mode)
  }

  #removeRegionalSource(id: string): void {
    if (!this.#map) return
    detachRegionalSource(this.#map, id)
    this.#activeRegionalSources.delete(id)
  }

  /** Streaming is opt-out in Settings, on top of the shared device rules. */
  #onlineTilesActive(): boolean {
    return app.onlineTilesEnabled && canStreamTiles()
  }

  /**
   * Sync is driven by moveend, download completion, connectivity, and the
   * settings toggle, which can overlap. Serializing keeps one run from
   * tearing down a source another just added; requests arriving mid-run
   * coalesce into a single follow-up.
   */
  #syncRegionalTilesForViewport(): Promise<void> {
    if (this.#syncPending) return this.#syncQueue
    this.#syncPending = true
    this.#syncQueue = this.#syncQueue.then(() => {
      this.#syncPending = false
      return this.#runRegionalSync()
    }).catch((err) => {
      console.error('Regional tile sync failed:', err)
    })
    return this.#syncQueue
  }

  /**
   * Reconciles the attached regional sources with what the viewport wants. A
   * downloaded region always beats the streamed copy of the same archive —
   * same file, no bandwidth — so streaming only fills the gaps.
   */
  async #runRegionalSync(): Promise<void> {
    if (!this.#map || !this.#styleReady) return
    const tiles = this.#tileManifest.length
      ? this.#tileManifest
      : await fetchTileManifest()
    this.#tileManifest = tiles

    const desired = new Map<string, SourceMode>()
    let status: OnlineStatus = 'hidden'
    const zoom = this.#map.getZoom()
    if (zoom >= MIN_REGIONAL_ZOOM) {
      const viewport = this.#map.getBounds()
      const buffered = expandBounds(viewport, VIEWPORT_BUFFER)
      const candidates = tiles.filter((t) =>
        t.filename && t.bounds && boundsIntersect(t.bounds, buffered)
      )
      const cached = await Promise.all(
        candidates.map((t) => isPMTilesCached(t.filename!)),
      )
      const streamable: TileManifestEntry[] = []
      candidates.forEach((t, i) => {
        if (cached[i]) desired.set(t.id, 'idb')
        else streamable.push(t)
      })

      // The badge only earns its space where streaming would change the
      // picture: close enough in, something in view that isn't downloaded,
      // and a connection to fetch it over.
      if (
        zoom >= MIN_ONLINE_ZOOM && streamable.length > 0 && navigator.onLine
      ) {
        status = dataSaverActive()
          ? 'saver'
          : app.onlineTilesEnabled
          ? 'streaming'
          : 'off'
      }

      if (zoom >= MIN_ONLINE_ZOOM && this.#onlineTilesActive()) {
        const { lng, lat } = viewport.getCenter()
        for (const t of pickStreamableRegions(streamable, lng, lat)) {
          desired.set(t.id, 'net')
        }
      }
    }

    // Comparing modes (not just presence) also catches a swap: a streamed
    // region that finished downloading, or one that lost its connection.
    for (const [id, mode] of [...this.#activeRegionalSources]) {
      if (desired.get(id) !== mode) this.#removeRegionalSource(id)
    }
    const tileById = new Map(tiles.map((t) => [t.id, t]))
    let attachedNewSource = false
    for (const [id, mode] of desired) {
      if (this.#activeRegionalSources.has(id)) continue
      const tile = tileById.get(id)
      if (tile) {
        await this.#addRegionalSource(tile, mode)
        attachedNewSource = true
      }
    }

    if (status !== this.#onlineStatus) {
      this.#onlineStatus = status
      this.requestUpdate()
    }

    // Each newly attached source registers its own copy of the transit
    // layers (see layers.ts); reconcile them to the current preference so
    // they don't flash visible before the user's choice is applied.
    if (attachedNewSource) {
      this.#setTransitLayersVisible(app.transitLayerEnabled)
    }

    ensureBookmarkLayersOnTop(this.#map)
  }
}

customElements.define('m-map', MMap)
