import { html, LitElement, type TemplateResult } from 'lit'
import maplibregl from 'maplibre-gl'
import { debounce } from '@std/async/debounce'
// Imported for the side effect: registers the shared `pmtiles://` handler.
// This map only ever uses URL-backed sources, so it needs no instance of its
// own — and must not create one, or the main map's IndexedDB archives break.
import '../utils/pmtiles-protocol.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 {
  type Bounds,
  boundsIntersect,
  expandBounds,
  toBounds,
} from '../utils/bounds.ts'
import {
  fetchTileManifest,
  type TileManifestEntry,
} from '../utils/tile-manifest.ts'
import {
  canStreamTiles,
  MIN_ONLINE_ZOOM,
  pickStreamableRegions,
  streamedTileUrl,
  TILE_HOST,
  VIEWPORT_BUFFER,
} from '../utils/online-tiles.ts'
import {
  buildSharedPinsGeoJSON,
  ensureBookmarkLayersOnTop,
  setupBookmarkLayers,
} from '../utils/bookmark-layers.ts'
import type { SharedBookmark } from '../store/schema.ts'

/**
 * Opening zoom for a single location. The author's own framing is respected,
 * but floored at street level — the point of looking at a location is seeing
 * where it actually is — and capped short of heavy overzoom, since the
 * archives stop at z14.
 */
const MIN_POINT_ZOOM = 14
const MAX_POINT_ZOOM = 16

/**
 * Initial center/zoom for a multi-point list, before `#fit()` frames the
 * actual locations — matches `m-map.ts`'s own "nothing to focus on yet"
 * default, so a list reveals itself by narrowing in from a world view
 * instead of jumping from the first point out to the full set.
 */
const WORLD_CENTER: [number, number] = [20, 20]
const WORLD_ZOOM = 2

/** Fired whenever the map's viewport settles — see {@link MiniMapViewportDetail}. */
export const MINI_MAP_VIEWPORT_EVENT = 'mini-map-viewport'
export type MiniMapViewportDetail = { bounds: Bounds }

/**
 * A small, self-contained map for one location or a set of them.
 *
 * Used by the bookmark detail page and the shared-list viewer, both of which
 * need the same thing: a basemap, pins, and street detail streamed in for
 * whatever region is on screen. It reads only the public
 * {@link SharedBookmark} projection, so it renders a stranger's shared
 * payload and the owner's own bookmark through exactly one path.
 */
export class MMiniMap extends LitElement {
  static override properties = {
    // Reactive but attribute-less: a re-assignment (a share resolving, an
    // edit landing) has to schedule an update, or the pins would only ever
    // reflect whatever was set before the first paint.
    points: { attribute: false },
    interactive: { type: Boolean },
    color: {},
    initialViewport: { attribute: false },
    transitEnabled: { type: Boolean },
  }

  /** Locations to plot. Assign as a property, not an attribute. */
  declare points: SharedBookmark[]
  /** Pin colour — the owning list's, when there is one. */
  declare color: string | null
  declare interactive: boolean
  /**
   * Overrides the initial center/zoom a multi-point list would otherwise
   * open on (world view, then `#fit()` narrows to every point) — set from a
   * share link published with "current view" (`app.publishList`'s
   * `useCurrentView`). Framing then stays exactly where the sharer left it;
   * `#fit()` is skipped so it doesn't zoom back out to fit everyone.
   */
  declare initialViewport: { lat: number; lng: number; zoom: number } | null
  /** Whether the transit route/station overlay renders once a region streams
   * in. Defaults true, matching the map's own default. */
  declare transitEnabled: boolean

  #map: maplibregl.Map | null = null
  /**
   * Latches on the map's one-time `load` event. Gating re-plots on
   * `map.loaded()` instead would drop them: `loaded()` is false whenever any
   * tile is still in flight, and nothing would retry the skipped plot.
   */
  #styleReady = false
  #manifest: TileManifestEntry[] = []
  #activeSources = new Set<string>()
  #plotted = ''
  #popup: maplibregl.Popup | null = null
  /** See the matching field in `m-map.ts` for why this can't just be CSS. */
  #dark = isDarkTheme()
  #stopWatchingTheme: (() => void) | null = null

  constructor() {
    super()
    this.points = []
    this.color = null
    this.interactive = true
    this.initialViewport = null
    this.transitEnabled = true
  }

  protected override createRenderRoot() {
    return this
  }

  override connectedCallback() {
    super.connectedCallback()
    this.#stopWatchingTheme = watchMapTheme(this.#onThemeChange)
  }

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

  override disconnectedCallback() {
    super.disconnectedCallback()
    this.#stopWatchingTheme?.()
    this.#stopWatchingTheme = null
    this.#onMoveEnd.clear()
    this.#popup?.remove()
    this.#popup = null
    this.#map?.remove()
    this.#map = null
    this.#styleReady = false
    this.#plotted = ''
    this.#activeSources.clear()
  }

  #onMoveEnd = debounce(() => {
    void this.#syncStreamedRegions()
    this.#dispatchBounds()
  }, 150)

  /** Lets a list beside the map filter to whatever's actually on screen. */
  #dispatchBounds(): void {
    const map = this.#map
    if (!map) return
    this.dispatchEvent(
      new CustomEvent<MiniMapViewportDetail>(MINI_MAP_VIEWPORT_EVENT, {
        detail: { bounds: toBounds(map.getBounds()) },
        bubbles: true,
        composed: true,
      }),
    )
  }

  override render(): TemplateResult {
    return html`
      <div class="mini-map"></div>
    `
  }

  override updated(): void {
    if (!this.points.length) return
    if (!this.#map) {
      this.#initMap()
      return
    }
    // Points can arrive after the first paint (a share fetch resolving, or an
    // edit landing); re-plot only when they actually differ. Before `load`,
    // the load handler plots whatever is current.
    const key = this.#pointsKey()
    if (key !== this.#plotted && this.#styleReady) this.#plot()
  }

  #pointsKey(): string {
    return this.points.map((p) => `${p.coordinates.join(',')}:${p.name}`)
      .join('|')
  }

  #initMap(): void {
    const container = this.querySelector<HTMLElement>('.mini-map')
    if (!container || !this.points.length) return

    // A single location opens framed on itself — there's nothing to fit.
    // A list opens on a world view and lets `#fit()` (triggered by `load`,
    // once the map exists to fit bounds against) animate the narrowing zoom
    // into its actual footprint, rather than jumping from the first point
    // out to the full set — unless the sharer baked their own framing into
    // the link (`initialViewport`), in which case that wins outright and
    // `#fit()` stays out of it (see `#plot`).
    const multiple = this.points.length > 1
    const [lng, lat] = this.points[0].coordinates
    const override = multiple ? this.initialViewport : null
    const zoom = override
      ? override.zoom
      : multiple
      ? WORLD_ZOOM
      : Math.min(Math.max(this.points[0].zoom, MIN_POINT_ZOOM), MAX_POINT_ZOOM)
    const center: [number, number] = override
      ? [override.lng, override.lat]
      : multiple
      ? WORLD_CENTER
      : [lng, lat]

    try {
      this.#map = new maplibregl.Map({
        container,
        center,
        zoom,
        interactive: this.interactive,
        // The map sits inside a scrolling page, so a plain wheel scroll or
        // one-finger touch belongs to the document — cooperativeGestures
        // requires ctrl/cmd + wheel (two fingers on touch) before it zooms,
        // on both the fully interactive and the fixed-location map.
        cooperativeGestures: true,
        attributionControl: false,
        pitchWithRotate: false,
        style: {
          version: 8,
          glyphs: `${TILE_HOST}/static/fonts/{fontstack}/{range}.pbf`,
          sources: {
            world: {
              type: 'vector',
              url: `pmtiles://${TILE_HOST}/static/tiles/world/world_z5.pmtiles`,
              attribution: '© OpenStreetMap contributors',
            },
          },
          layers: [
            {
              id: 'background',
              type: 'background',
              paint: { 'background-color': mapBackground(this.#dark) },
            },
            ...(buildWorldLayers(
              this.#dark,
            ) as maplibregl.LayerSpecification[]),
          ],
        },
      })

      this.#map.addControl(
        new maplibregl.AttributionControl({ compact: true }),
        'bottom-right',
      )
      if (this.interactive) {
        this.#map.addControl(
          new maplibregl.NavigationControl({ showCompass: false }),
          'top-right',
        )
      } else {
        // Fixed location, no buttons: `interactive: false` disables every
        // handler at setup, so scroll/pinch zoom need re-enabling by hand.
        // `around: 'center'` anchors the zoom on the focused location rather
        // than the cursor/pinch midpoint — with dragPan off, drifting off
        // that point on zoom would leave no way to pan back to it. Rotation
        // stays off — only the zoom gesture makes sense here.
        this.#map.scrollZoom.enable({ around: 'center' })
        this.#map.touchZoomRotate.enable({ around: 'center' })
        this.#map.touchZoomRotate.disableRotation()
      }

      this.#map.on('moveend', this.#onMoveEnd)
      this.#map.on('load', () => {
        this.#styleReady = true
        void ensureTrainStationIcon(this.#map!)
        void ensureStationLogoIcons(this.#map!)
        this.#plot()
        void this.#syncStreamedRegions()
        // A single-point list never triggers `#fit()`'s `fitBounds`, so
        // `moveend` (and the bounds dispatch riding on it) would otherwise
        // never fire — a consumer filtering by viewport needs this either way.
        this.#dispatchBounds()
      })
    } catch {
      // Without a basemap the surrounding page still shows the coordinates.
      this.#map = null
    }
  }

  /** Draws the pins and frames them. */
  #plot(): void {
    const map = this.#map
    if (!map) return
    this.#plotted = this.#pointsKey()

    const data = buildSharedPinsGeoJSON(this.points, this.color)
    const source = map.getSource('bookmarks') as
      | maplibregl.GeoJSONSource
      | undefined
    if (source) {
      source.setData(data)
    } else {
      setupBookmarkLayers(map, data, {
        onClusterClick: async (clusterId, coords) => {
          const src = map.getSource('bookmarks') as maplibregl.GeoJSONSource
          const zoom = await src.getClusterExpansionZoom(clusterId)
          map.easeTo({ center: coords, zoom })
        },
        onBookmarkClick: (coords, _id, displayName) => {
          new maplibregl.Popup({ offset: 16 })
            .setLngLat(coords)
            .setText(displayName)
            .addTo(map)
        },
      })
    }
    // A baked-in `initialViewport` is the sharer's chosen framing — leave it
    // alone. `resetView()` (the "Show all" button) still calls `#fit()`
    // directly, so that reset keeps working regardless.
    if (!this.initialViewport) this.#fit()
    this.#openSolePopup()
  }

  /**
   * Shows/hides the transit route + station overlay across every currently
   * attached source — mirrors `m-map.ts`'s method of the same name. Safe to
   * call before any source is 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',
      )
    }
  }

  /**
   * A single location gets its label showing without a tap — there's nothing
   * to disambiguate, and an unlabelled pin reads as less informative than it
   * is. Sets of points stay quiet; opening every popup would be noise.
   */
  #openSolePopup(): void {
    const map = this.#map
    if (!map || this.points.length !== 1) return
    this.#popup?.remove()
    this.#popup = new maplibregl.Popup({
      offset: 16,
      closeButton: false,
      closeOnClick: false,
    })
      .setLngLat(this.points[0].coordinates)
      .setText(this.points[0].name)
      .addTo(map)
  }

  /** Frames every point rather than whichever happened to be first. */
  #fit(): void {
    const map = this.#map
    if (!map || this.points.length < 2) return
    const coords = this.points.map((p) => p.coordinates)
    const bounds = coords.reduce(
      (acc, c) => acc.extend(c),
      new maplibregl.LngLatBounds(coords[0], coords[0]),
    )
    map.fitBounds(bounds, { padding: 48, maxZoom: MAX_POINT_ZOOM })
  }

  /** Pans to one location — used by the location list beside the map. */
  focusPoint(coordinates: [number, number]): void {
    this.#map?.flyTo({ center: coordinates, zoom: MAX_POINT_ZOOM })
    this.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
  }

  /** Reframes every point — the "show all" reset for a viewport-filtered list. */
  resetView(): void {
    this.#fit()
    // `#fit()` no-ops below 2 points and won't trigger the `moveend` that
    // would otherwise re-dispatch bounds.
    if (this.points.length < 2) this.#dispatchBounds()
  }

  /**
   * Attaches streamed regional archives for the current viewport. There are
   * no downloaded regions to prefer here and no preferences to consult — a
   * first-time visitor has neither — so streaming is the only way this map
   * shows streets at all. The thresholds are shared with the app map via
   * `utils/online-tiles.ts`.
   */
  async #syncStreamedRegions(): Promise<void> {
    const map = this.#map
    if (!map) return
    if (!this.#manifest.length) this.#manifest = await fetchTileManifest()

    const desired = new Set<string>()
    if (map.getZoom() >= MIN_ONLINE_ZOOM && canStreamTiles()) {
      const viewport = map.getBounds()
      const buffered = expandBounds(viewport, VIEWPORT_BUFFER)
      const { lng, lat } = viewport.getCenter()
      const candidates = this.#manifest.filter((t) =>
        t.filename && t.bounds && boundsIntersect(t.bounds, buffered)
      )
      for (const t of pickStreamableRegions(candidates, lng, lat)) {
        desired.add(t.id)
      }
    }

    for (const id of [...this.#activeSources]) {
      if (desired.has(id)) continue
      detachRegionalSource(map, id)
      this.#activeSources.delete(id)
    }

    const byId = new Map(this.#manifest.map((t) => [t.id, t]))
    let attachedNewSource = false
    for (const id of desired) {
      if (this.#activeSources.has(id)) continue
      const tile = byId.get(id)
      if (!tile?.filename) continue
      attachRegionalSource(map, id, streamedTileUrl(tile.filename), this.#dark)
      this.#activeSources.add(id)
      attachedNewSource = true
    }

    // Each newly attached source registers its own copy of the transit
    // layers (see layers.ts); reconcile them to `transitEnabled` so they
    // don't flash visible before it's applied.
    if (attachedNewSource) this.#setTransitLayersVisible(this.transitEnabled)

    // Newly attached regional layers land above the pins otherwise.
    if (map.getSource('bookmarks')) ensureBookmarkLayersOnTop(map)
  }
}

customElements.define('m-mini-map', MMiniMap)
