<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import type * as Leaflet from 'leaflet'
import 'leaflet/dist/leaflet.css'
import 'leaflet-draw/dist/leaflet.draw.css'
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'
import markerIcon from 'leaflet/dist/images/marker-icon.png'
import markerShadow from 'leaflet/dist/images/marker-shadow.png'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'

// The editing toolbar (add/edit/delete shapes) only shows up for admins; a
// public visitor sees the drawn plan read-only.
const { editable = false } = defineProps<{ editable?: boolean }>()

const geojson = defineModel<GeoJSON.FeatureCollection>({ required: true })

const { t } = useI18n()

const EPFL_CENTER: [number, number] = [46.5185, 6.5666]
const EPFL_INITIAL_ZOOM = 22

// EPFL's building outlines are only published through plan.epfl.ch's own
// (public, unauthenticated, but undocumented) tile service, in the Swiss
// national grid (EPSG:2056) rather than the usual Web Mercator one — so the
// whole map has to use that grid to align the OSM base layer with the
// building tiles. Origin and per-zoom resolutions come straight from that
// service's own WMTS capabilities document (prod-plan-epfl-tiles*.epfl.ch/
// 1.0.0/WMTSCapabilities_prod_2056.xml), not guessed.
const SWISS_PROJ4_DEF =
  '+proj=somerc +lat_0=46.95240555555556 +lon_0=7.439583333333333 +k_0=1 +x_0=2600000 +y_0=1200000 +ellps=bessel +towgs84=674.374,15.056,405.346,0,0,0,0 +units=m +no_defs'
const SWISS_GRID_ORIGIN: [number, number] = [2420000, 1350000]
// Actual tile data only goes up to index 29 (buildings) / 28 (base map,
// aerial). A couple more levels are appended so the map can still be zoomed
// in further than that: Leaflet then just upscales the deepest available
// tile instead of fetching a non-existent one (`maxNativeZoom` below).
const NATIVE_RESOLUTIONS = [
  4000, 3750, 3500, 3250, 3000, 2750, 2500, 2250, 2000, 1750, 1500, 1250, 1000, 750, 650, 500,
  250, 100, 50, 20, 10, 5, 2.5, 2, 1.5, 1, 0.5, 0.25, 0.1, 0.05,
]
const EXTRA_ZOOM_LEVELS = 2
const SWISS_RESOLUTIONS = [
  ...NATIVE_RESOLUTIONS,
  ...Array.from(
    { length: EXTRA_ZOOM_LEVELS },
    (_, i) => NATIVE_RESOLUTIONS[NATIVE_RESOLUTIONS.length - 1] / 2 ** (i + 1),
  ),
]
const MAX_ZOOM = SWISS_RESOLUTIONS.length - 1
const BUILDINGS_MAX_NATIVE_ZOOM = NATIVE_RESOLUTIONS.length - 1
const BASE_LAYERS_MAX_NATIVE_ZOOM = NATIVE_RESOLUTIONS.length - 2

// `{floorPath}` is "" for the base map and "{floor}/" for the buildings
// layer, matching the two real URL shapes observed on plan.epfl.ch:
//   .../osm-wmts/default/{date}/2056/{z}/{row}/{col}.png
//   .../batiments/default/{date}/{floor}/2056/{z}/{row}/{col}.png
// Note the path is TileMatrix/TileRow/TileCol (OGC WMTS REST convention,
// i.e. z/y/x) rather than the more common z/x/y "slippy map" order.
const EPFL_TILES_URL =
  'https://prod-plan-epfl-tiles{s}.epfl.ch/1.0.0/{layer}/default/{date}/{floorPath}2056/{z}/{y}/{x}.png'
const EPFL_TILE_SUBDOMAINS = ['0', '1', '2', '3', '4']

// The aerial/satellite imagery is served separately, by the official (public,
// documented) Swiss geoportal, on the same grid (origin and resolutions
// verified against its own WMTS capabilities document) but NOT the same URL
// convention: swisstopo's REST template is TileMatrix/TileCol/TileRow, i.e.
// z/x/y ("slippy map" order), unlike EPFL's own proxy above (z/y/x).
const AERIAL_TILES_URL =
  'https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage-product/default/current/2056/{z}/{x}/{y}.jpeg'

interface EpflTileLayerOptions extends Leaflet.TileLayerOptions {
  layer: string
  date: string
  floorPath: string
}

const DEFAULT_COLOR = '#3388ff'
const DASHED_PATTERN = '8, 8'

const floor = ref(0)
const backgroundPanelOpen = ref(false)
const showMap = ref(true)
const showAerial = ref(false)
const aerialOpacity = ref(1)

// Style applied to newly drawn shapes.
const drawColor = ref(DEFAULT_COLOR)
const drawDashed = ref(false)

const styleDialogOpen = ref(false)
const styleForm = reactive({ color: DEFAULT_COLOR, dashed: false })

const annotationDialogOpen = ref(false)
const annotationKind = ref<'text' | 'icon'>('text')
const annotationForm = reactive({ text: '', svg: '' })

const mapContainer = ref<HTMLDivElement>()
let map: Leaflet.Map | undefined
let osmLayer: Leaflet.TileLayer | undefined
let aerialLayer: Leaflet.TileLayer | undefined
let buildingsLayer: Leaflet.TileLayer | undefined
let drawnItems: Leaflet.FeatureGroup | undefined
let LeafletLib: typeof Leaflet | undefined

function floorOf(feature: GeoJSON.Feature): number {
  const value = feature.properties?.floor
  return typeof value === 'number' ? value : 0
}

function escapeHtml(value: string): string {
  const div = document.createElement('div')
  div.textContent = value
  return div.innerHTML
}

// Minimal SVG sanitizer: strips <script>/<foreignObject> and any event
// handler or javascript: URI, since this markup gets rendered for every
// visitor of the (public) plan. Not a full sanitizer, but covers the
// common XSS vectors for a narrow "paste an icon" use case.
function sanitizeSvg(svgString: string): string | null {
  let doc: Document
  try {
    doc = new DOMParser().parseFromString(svgString, 'image/svg+xml')
  } catch {
    return null
  }
  const root = doc.documentElement
  if (!root || root.nodeName.toLowerCase() !== 'svg' || doc.querySelector('parsererror')) {
    return null
  }
  const stripDangerousAttrs = (el: Element) => {
    for (const attr of Array.from(el.attributes)) {
      const name = attr.name.toLowerCase()
      const value = attr.value.trim().toLowerCase()
      if (name.startsWith('on') || ((name === 'href' || name === 'xlink:href') && value.startsWith('javascript:'))) {
        el.removeAttribute(attr.name)
      }
    }
  }
  const clean = (node: Element) => {
    stripDangerousAttrs(node)
    for (const child of Array.from(node.children)) {
      const tag = child.tagName.toLowerCase()
      if (tag === 'script' || tag === 'foreignobject') {
        child.remove()
        continue
      }
      clean(child)
    }
  }
  clean(root)
  return new XMLSerializer().serializeToString(root)
}

function annotationHtml(properties: GeoJSON.GeoJsonProperties): string {
  if (properties?.kind === 'text') {
    const color = typeof properties.color === 'string' ? properties.color : DEFAULT_COLOR
    return `<div class="plan-annotation-text" style="color:${color}">${escapeHtml(String(properties.text ?? ''))}</div>`
  }
  if (typeof properties?.emoji === 'string') {
    return `<div class="plan-annotation-icon">${escapeHtml(properties.emoji)}</div>`
  }
  // Re-sanitize on every render, not just at creation time: the stored
  // `plan` blob is opaque to the backend (no server-side validation), so
  // this is the actual trust boundary — anything that ends up in the
  // database must still be safe to render for every future visitor.
  const svg = typeof properties?.svg === 'string' ? (sanitizeSvg(properties.svg) ?? '') : ''
  return `<div class="plan-annotation-icon">${svg}</div>`
}

function makeAnnotationIcon(properties: GeoJSON.GeoJsonProperties): Leaflet.DivIcon {
  return LeafletLib!.divIcon({
    html: annotationHtml(properties),
    className: 'plan-annotation-marker',
    iconSize: [32, 32],
    iconAnchor: [16, 16],
  })
}

type StyledLayer = Leaflet.Path & { feature?: GeoJSON.Feature }

let editingStyleLayer: StyledLayer | undefined

function hasSetStyle(layer: Leaflet.Layer): layer is StyledLayer {
  return typeof (layer as unknown as Partial<Leaflet.Path>).setStyle === 'function'
}

function attachStyleClickHandler(layer: Leaflet.Layer) {
  if (!editable || !hasSetStyle(layer)) return
  layer.on('click', () => openStyleEditor(layer))
}

function openStyleEditor(layer: StyledLayer) {
  editingStyleLayer = layer
  const properties = layer.feature?.properties ?? {}
  styleForm.color = typeof properties.color === 'string' ? properties.color : DEFAULT_COLOR
  styleForm.dashed = !!properties.dashArray
  styleDialogOpen.value = true
}

function applyStyleToSelected() {
  if (!editingStyleLayer) return
  const dashArray = styleForm.dashed ? DASHED_PATTERN : undefined
  editingStyleLayer.setStyle({ color: styleForm.color, dashArray })
  editingStyleLayer.feature = {
    type: 'Feature',
    geometry: editingStyleLayer.feature?.geometry ?? (null as never),
    properties: {
      ...editingStyleLayer.feature?.properties,
      color: styleForm.color,
      dashArray: dashArray ?? null,
    },
  }

  styleDialogOpen.value = false
  emitCurrentShapes()
}

function openAnnotationDialog(kind: 'text' | 'icon') {
  annotationKind.value = kind
  annotationForm.text = ''
  annotationForm.svg = ''
  annotationDialogOpen.value = true
}

function confirmAnnotation() {
  if (!map || !LeafletLib || !drawnItems) return
  const L = LeafletLib

  const properties: GeoJSON.GeoJsonProperties = { floor: floor.value, kind: annotationKind.value, color: drawColor.value }
  if (annotationKind.value === 'text') {
    if (!annotationForm.text.trim()) return
    properties.text = annotationForm.text.trim()
  } else {
    const raw = annotationForm.svg.trim()
    if (!raw) return
    if (raw.startsWith('<')) {
      const cleaned = sanitizeSvg(raw)
      if (!cleaned) {
        toast.error(t('admin.invalid-svg'))
        return
      }
      properties.svg = cleaned
    } else {
      // Not SVG markup: treat it as plain text (e.g. an emoji).
      properties.emoji = raw
    }
  }

  const marker = L.marker(map.getCenter(), {
    draggable: true,
    pane: 'drawingsPane',
    icon: makeAnnotationIcon(properties),
  }) as Leaflet.Marker & { feature?: GeoJSON.Feature }
  marker.feature = { type: 'Feature', properties, geometry: null as never }
  marker.on('dragend', emitCurrentShapes)

  drawnItems.addLayer(marker)
  annotationDialogOpen.value = false
  emitCurrentShapes()
}

function loadFloorIntoDrawnItems() {
  if (!drawnItems || !LeafletLib) return
  const L = LeafletLib
  drawnItems.clearLayers()
  const features = (geojson.value.features ?? []).filter((f) => floorOf(f) === floor.value)
  const collection: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features }
  L.geoJSON(collection, {
    pointToLayer: (feature, latlng) => {
      if (feature.properties?.kind === 'text' || feature.properties?.kind === 'icon') {
        const marker = L.marker(latlng, {
          draggable: editable,
          pane: 'drawingsPane',
          icon: makeAnnotationIcon(feature.properties),
        })
        if (editable) marker.on('dragend', emitCurrentShapes)
        return marker
      }
      return L.marker(latlng, { pane: 'drawingsPane' })
    },
    style: (feature) => ({
      pane: 'drawingsPane',
      color: typeof feature?.properties?.color === 'string' ? feature.properties.color : DEFAULT_COLOR,
      dashArray:
        typeof feature?.properties?.dashArray === 'string' ? feature.properties.dashArray : undefined,
    }),
  }).eachLayer((layer) => {
    attachStyleClickHandler(layer)
    drawnItems?.addLayer(layer)
  })
}

function emitCurrentShapes() {
  if (!drawnItems) return
  const otherFloors = (geojson.value.features ?? []).filter((f) => floorOf(f) !== floor.value)
  const currentFloor = (drawnItems.toGeoJSON() as GeoJSON.FeatureCollection).features
  geojson.value = {
    type: 'FeatureCollection',
    features: [...otherFloors, ...currentFloor],
  }
}

onMounted(async () => {
  // leaflet-draw and proj4leaflet are old-style plugins that patch the
  // global `L` object rather than importing leaflet themselves, so `window.L`
  // must be set before they load. Dynamic imports (unlike static ones) run in
  // the order awaited, which is what makes this ordering reliable.
  const L = (await import('leaflet')).default
  LeafletLib = L
  ;(window as unknown as { L: typeof L }).L = L
  await import('proj4leaflet')

  // Leaflet's default marker icon paths break under bundlers: point them at
  // the actual bundled asset URLs instead.
  delete (L.Icon.Default.prototype as { _getIconUrl?: unknown })._getIconUrl
  L.Icon.Default.mergeOptions({
    iconRetinaUrl: markerIcon2x,
    iconUrl: markerIcon,
    shadowUrl: markerShadow,
  })

  if (!mapContainer.value) return

  const swissCrs = new L.Proj.CRS('EPSG:2056', SWISS_PROJ4_DEF, {
    origin: SWISS_GRID_ORIGIN,
    resolutions: SWISS_RESOLUTIONS,
  })

  map = L.map(mapContainer.value, {
    crs: swissCrs as unknown as Leaflet.CRS,
    minZoom: 0,
    maxZoom: MAX_ZOOM,
  }).setView(EPFL_CENTER, EPFL_INITIAL_ZOOM)

  // Dedicated panes (rather than each TileLayer's own `zIndex` option) so
  // stacking is robust across zoom-transition animations, which otherwise
  // apply their own z-index to the layer's internal wrapper. Note: Leaflet
  // strips the "Pane" suffix from the name to build the CSS class, so e.g.
  // "mapPane" would collide with Leaflet's own built-in `leaflet-map-pane`
  // (the whole map's transform container) — hence "osmBasePane" here.
  map.createPane('osmBasePane').style.zIndex = '200'
  map.createPane('aerialPane').style.zIndex = '300'
  map.createPane('buildingsPane').style.zIndex = '400'
  map.createPane('drawingsPane').style.zIndex = '500'

  // Each TileLayer's own maxZoom defaults to 18 regardless of the map's: it
  // must be raised to match our resolutions array, or GridLayer silently
  // renders nothing once the map zoom exceeds it. maxNativeZoom caps where
  // Leaflet stops fetching new tiles and starts upscaling the deepest one.
  const baseZoomBounds = { minZoom: 0, maxZoom: MAX_ZOOM, maxNativeZoom: BASE_LAYERS_MAX_NATIVE_ZOOM }
  const buildingsZoomBounds = { minZoom: 0, maxZoom: MAX_ZOOM, maxNativeZoom: BUILDINGS_MAX_NATIVE_ZOOM }

  osmLayer = L.tileLayer(EPFL_TILES_URL, {
    ...baseZoomBounds,
    pane: 'osmBasePane',
    subdomains: EPFL_TILE_SUBDOMAINS,
    layer: 'osm-wmts',
    date: '20250317',
    floorPath: '',
    attribution: '&copy; EPFL &middot; &copy; OpenStreetMap contributors',
  } as EpflTileLayerOptions)
  if (showMap.value) osmLayer.addTo(map)

  aerialLayer = L.tileLayer(AERIAL_TILES_URL, {
    ...baseZoomBounds,
    pane: 'aerialPane',
    opacity: aerialOpacity.value,
    attribution: '&copy; swisstopo',
  })
  if (showAerial.value) aerialLayer.addTo(map)

  buildingsLayer = L.tileLayer(EPFL_TILES_URL, {
    ...buildingsZoomBounds,
    pane: 'buildingsPane',
    subdomains: EPFL_TILE_SUBDOMAINS,
    layer: 'batiments',
    date: '20231011',
    floorPath: `${floor.value}/`,
  } as EpflTileLayerOptions).addTo(map)

  drawnItems = L.featureGroup().addTo(map)
  loadFloorIntoDrawnItems()

  if (editable) {
    await import('leaflet-draw')

    // leaflet-draw 1.0.4's GeometryUtil.readableArea assigns to an
    // undeclared `type` variable, which throws under strict-mode ESM
    // bundling (it silently leaked a global in classic <script> usage
    // instead). Replace it with an equivalent metric-only implementation.
    L.GeometryUtil.readableArea = (area: number) => {
      if (area >= 1_000_000) return `${(area / 1_000_000).toFixed(2)} km²`
      if (area >= 10_000) return `${(area / 10_000).toFixed(2)} ha`
      return `${Math.round(area)} m²`
    }

    const drawControl = new L.Control.Draw({
      edit: { featureGroup: drawnItems },
      draw: {
        marker: { pane: 'drawingsPane' } as unknown as Leaflet.MarkerOptions,
        polyline: { shapeOptions: { pane: 'drawingsPane' } },
        polygon: { shapeOptions: { pane: 'drawingsPane' } },
        rectangle: { shapeOptions: { pane: 'drawingsPane' } },
        circle: { shapeOptions: { pane: 'drawingsPane' } },
        circlemarker: false,
      },
    })
    map.addControl(drawControl)

    map.on(L.Draw.Event.CREATED, (e) => {
      const event = e as Leaflet.DrawEvents.Created
      const layer = event.layer as Leaflet.Layer & { feature?: GeoJSON.Feature }

      const properties: GeoJSON.GeoJsonProperties = { floor: floor.value }
      if (hasSetStyle(layer)) {
        const dashArray = drawDashed.value ? DASHED_PATTERN : undefined
        layer.setStyle({ color: drawColor.value, dashArray })
        properties.color = drawColor.value
        properties.dashArray = dashArray ?? null
      }
      layer.feature = { type: 'Feature', properties, geometry: null as never }

      attachStyleClickHandler(layer)
      drawnItems?.addLayer(layer)
      emitCurrentShapes()
    })
    map.on(L.Draw.Event.EDITED, emitCurrentShapes)
    map.on(L.Draw.Event.DELETED, emitCurrentShapes)
  }
})

watch(floor, (value) => {
  if (buildingsLayer) {
    ;(buildingsLayer.options as EpflTileLayerOptions).floorPath = `${value}/`
    buildingsLayer.redraw()
  }
  loadFloorIntoDrawnItems()
})

watch(showMap, (value) => {
  if (!map || !osmLayer) return
  if (value) osmLayer.addTo(map)
  else map.removeLayer(osmLayer)
})

watch(showAerial, (value) => {
  if (!map || !aerialLayer) return
  if (value) aerialLayer.addTo(map)
  else map.removeLayer(aerialLayer)
})

watch(aerialOpacity, (value) => {
  aerialLayer?.setOpacity(value)
})

function changeFloor(delta: number) {
  floor.value += delta
}

onBeforeUnmount(() => {
  map?.remove()
})
</script>

<template>
  <div class="flex flex-col gap-2">
    <div class="flex flex-wrap items-center gap-2">
      <Button type="button" variant="outline" size="icon-sm" @click="changeFloor(-1)">-</Button>
      <span class="w-24 text-center text-sm">{{ $t('admin.floor-label', { floor }) }}</span>
      <Button type="button" variant="outline" size="icon-sm" @click="changeFloor(1)">+</Button>

      <template v-if="editable">
        <label class="ml-2 flex items-center gap-1 text-sm">
          {{ $t('admin.draw-color') }}
          <input v-model="drawColor" type="color" class="h-7 w-9 cursor-pointer rounded border" />
        </label>
        <select v-model="drawDashed" class="rounded-md border px-2 py-1 text-sm">
          <option :value="false">{{ $t('admin.line-solid') }}</option>
          <option :value="true">{{ $t('admin.line-dashed') }}</option>
        </select>
        <Button type="button" variant="outline" size="sm" @click="openAnnotationDialog('text')">
          {{ $t('admin.add-text') }}
        </Button>
        <Button type="button" variant="outline" size="sm" @click="openAnnotationDialog('icon')">
          {{ $t('admin.add-icon') }}
        </Button>
      </template>

      <div class="relative ml-auto">
        <Button
          type="button"
          variant="outline"
          size="sm"
          @click="backgroundPanelOpen = !backgroundPanelOpen"
        >
          {{ $t('admin.background-layer') }}
        </Button>
        <div
          v-if="backgroundPanelOpen"
          class="bg-background absolute right-0 z-[1000] mt-1 w-56 rounded-md border p-3 shadow-md"
        >
          <label class="flex items-center gap-2 text-sm">
            <input v-model="showMap" type="checkbox" />
            {{ $t('admin.layer-map') }}
          </label>
          <label class="mt-2 flex items-center gap-2 text-sm">
            <input v-model="showAerial" type="checkbox" />
            {{ $t('admin.layer-aerial') }}
          </label>
          <input
            v-if="showAerial"
            v-model.number="aerialOpacity"
            type="range"
            min="0"
            max="1"
            step="0.01"
            class="mt-2 w-full"
            :aria-label="t('admin.layer-aerial-opacity')"
          />
        </div>
      </div>
    </div>
    <p v-if="editable" class="text-muted-foreground text-xs">{{ $t('admin.annotation-hint') }}</p>
    <div ref="mapContainer" class="isolate h-[500px] w-full rounded-md border" />

    <Dialog v-model:open="styleDialogOpen">
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{{ $t('admin.style-title') }}</DialogTitle>
        </DialogHeader>
        <div class="flex flex-col gap-3">
          <label class="flex items-center gap-2 text-sm">
            {{ $t('admin.draw-color') }}
            <input v-model="styleForm.color" type="color" class="h-8 w-10 cursor-pointer rounded border" />
          </label>
          <select v-model="styleForm.dashed" class="rounded-md border px-2 py-1 text-sm">
            <option :value="false">{{ $t('admin.line-solid') }}</option>
            <option :value="true">{{ $t('admin.line-dashed') }}</option>
          </select>
        </div>
        <DialogFooter>
          <Button type="button" @click="applyStyleToSelected">{{ $t('admin.save') }}</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>

    <Dialog v-model:open="annotationDialogOpen">
      <DialogContent>
        <DialogHeader>
          <DialogTitle>
            {{ annotationKind === 'text' ? $t('admin.add-text') : $t('admin.add-icon') }}
          </DialogTitle>
        </DialogHeader>
        <form class="flex flex-col gap-3" @submit.prevent="confirmAnnotation">
          <Input
            v-if="annotationKind === 'text'"
            v-model="annotationForm.text"
            :placeholder="$t('admin.text-placeholder')"
            required
          />
          <template v-else>
            <p class="text-muted-foreground text-xs">{{ $t('admin.icon-hint') }}</p>
            <Textarea
              v-model="annotationForm.svg"
              rows="6"
              :placeholder="$t('admin.icon-placeholder')"
              required
            />
          </template>
          <DialogFooter>
            <Button type="submit">{{ $t('admin.add-to-plan') }}</Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  </div>
</template>

<style>
.plan-annotation-marker {
  background: transparent;
  border: none;
  overflow: visible;
}
.plan-annotation-text {
  width: max-content;
  max-width: 200px;
  font-weight: 600;
  font-size: 13px;
  padding: 1px 4px;
  background: rgba(255, 255, 255, 0.85);
  border-radius: 4px;
  white-space: pre-wrap;
}
.plan-annotation-icon {
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 24px;
  line-height: 1;
}
.plan-annotation-icon svg {
  width: 100%;
  height: 100%;
}
</style>
